From 23c051f789c68f4039463d3e0aac95debdbfafb3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Krzysztof=20Dre=C5=BCewski?= Date: Sun, 21 Jun 2026 16:33:15 +0200 Subject: [PATCH] task list on cloud web view --- ...2026-06-19_finish-self-hosted-auth-flow.md | 128 + .../2026-06-19_web-task-list-and-viewer.md | 124 + ...fix-share-button-disabled-null-settings.md | 91 + ai_plans/2026-06-20_remote-task-control.md | 158 ++ ...026-06-21_delete-shared-task-from-cloud.md | 66 + ...-bridge-config-section-undefined-logout.md | 74 + ...026-06-21_fix-bridge-socketio-mount-404.md | 62 + ...21_fix-extension-offline-on-shared-task.md | 85 + ...-21_fix-finished-task-empty-summary-bar.md | 89 + ...easoning-cut-stuck-early-partial-upsert.md | 101 + ...6-21_fix-share-response-null-zod-reject.md | 70 + ..._fix-stop-button-enabled-when-task-idle.md | 74 + ...artial-spinners-duplicate-task-messages.md | 94 + ai_plans/2026-06-21_web-task-view-polish.md | 98 + packages/cloud/package.json | 1 + packages/cloud/src/CloudAPI.ts | 1 + .../cloud/src/bridge/BridgeOrchestrator.ts | 213 ++ .../__tests__/BridgeOrchestrator.test.ts | 160 ++ .../bridge/__tests__/commandHandlers.test.ts | 125 + packages/cloud/src/bridge/commandHandlers.ts | 81 + packages/cloud/src/bridge/types.ts | 48 + packages/cloud/src/index.ts | 5 + packages/types/src/cloud.ts | 56 +- pnpm-lock.yaml | 76 +- .../versions/c3d4e5f6a7b8_task_message_ts.py | 37 + .../d4e5f6a7b8c9_task_messages_unique_ts.py | 62 + self-hosted-cloudapi/config/settings.py | 17 +- self-hosted-cloudapi/pyproject.toml | 2 + self-hosted-cloudapi/src/auth/web_session.py | 119 + self-hosted-cloudapi/src/main.py | 27 +- self-hosted-cloudapi/src/models/task.py | 13 +- self-hosted-cloudapi/src/realtime/__init__.py | 1 + self-hosted-cloudapi/src/realtime/hub.py | 84 + self-hosted-cloudapi/src/realtime/sio.py | 240 ++ self-hosted-cloudapi/src/routers/browser.py | 44 + self-hosted-cloudapi/src/routers/extension.py | 22 +- self-hosted-cloudapi/src/routers/settings.py | 13 +- self-hosted-cloudapi/src/routers/web.py | 244 ++ .../src/services/bridge_service.py | 18 +- .../src/services/settings_service.py | 32 +- .../src/services/share_service.py | 55 +- .../src/services/telemetry_service.py | 117 +- self-hosted-cloudapi/src/web/static/app.css | 614 +++++ self-hosted-cloudapi/src/web/static/live.js | 337 +++ self-hosted-cloudapi/src/web/static/render.js | 560 ++++ .../src/web/static/vendor/marked.min.js | 1592 +++++++++++ .../src/web/static/vendor/purify.min.js | 1316 +++++++++ .../src/web/static/vendor/socket.io.min.js | 2369 +++++++++++++++++ .../src/web/templates/base.html | 25 + .../src/web/templates/not_found.html | 9 + .../src/web/templates/task_detail.html | 75 + .../src/web/templates/tasks_list.html | 29 + self-hosted-cloudapi/tests/test_bridge.py | 446 ++++ .../tests/test_web_and_share.py | 573 ++++ self-hosted-cloudapi/uv.lock | 74 + src/extension.ts | 19 +- src/extension/bridge.ts | 142 + 57 files changed, 11375 insertions(+), 32 deletions(-) create mode 100644 ai_plans/2026-06-19_finish-self-hosted-auth-flow.md create mode 100644 ai_plans/2026-06-19_web-task-list-and-viewer.md create mode 100644 ai_plans/2026-06-20_fix-share-button-disabled-null-settings.md create mode 100644 ai_plans/2026-06-20_remote-task-control.md create mode 100644 ai_plans/2026-06-21_delete-shared-task-from-cloud.md create mode 100644 ai_plans/2026-06-21_fix-bridge-config-section-undefined-logout.md create mode 100644 ai_plans/2026-06-21_fix-bridge-socketio-mount-404.md create mode 100644 ai_plans/2026-06-21_fix-extension-offline-on-shared-task.md create mode 100644 ai_plans/2026-06-21_fix-finished-task-empty-summary-bar.md create mode 100644 ai_plans/2026-06-21_fix-reasoning-cut-stuck-early-partial-upsert.md create mode 100644 ai_plans/2026-06-21_fix-share-response-null-zod-reject.md create mode 100644 ai_plans/2026-06-21_fix-stop-button-enabled-when-task-idle.md create mode 100644 ai_plans/2026-06-21_fix-stuck-partial-spinners-duplicate-task-messages.md create mode 100644 ai_plans/2026-06-21_web-task-view-polish.md create mode 100644 packages/cloud/src/bridge/BridgeOrchestrator.ts create mode 100644 packages/cloud/src/bridge/__tests__/BridgeOrchestrator.test.ts create mode 100644 packages/cloud/src/bridge/__tests__/commandHandlers.test.ts create mode 100644 packages/cloud/src/bridge/commandHandlers.ts create mode 100644 packages/cloud/src/bridge/types.ts create mode 100644 self-hosted-cloudapi/alembic/versions/c3d4e5f6a7b8_task_message_ts.py create mode 100644 self-hosted-cloudapi/alembic/versions/d4e5f6a7b8c9_task_messages_unique_ts.py create mode 100644 self-hosted-cloudapi/src/auth/web_session.py create mode 100644 self-hosted-cloudapi/src/realtime/__init__.py create mode 100644 self-hosted-cloudapi/src/realtime/hub.py create mode 100644 self-hosted-cloudapi/src/realtime/sio.py create mode 100644 self-hosted-cloudapi/src/routers/web.py create mode 100644 self-hosted-cloudapi/src/web/static/app.css create mode 100644 self-hosted-cloudapi/src/web/static/live.js create mode 100644 self-hosted-cloudapi/src/web/static/render.js create mode 100644 self-hosted-cloudapi/src/web/static/vendor/marked.min.js create mode 100644 self-hosted-cloudapi/src/web/static/vendor/purify.min.js create mode 100644 self-hosted-cloudapi/src/web/static/vendor/socket.io.min.js create mode 100644 self-hosted-cloudapi/src/web/templates/base.html create mode 100644 self-hosted-cloudapi/src/web/templates/not_found.html create mode 100644 self-hosted-cloudapi/src/web/templates/task_detail.html create mode 100644 self-hosted-cloudapi/src/web/templates/tasks_list.html create mode 100644 self-hosted-cloudapi/tests/test_bridge.py create mode 100644 self-hosted-cloudapi/tests/test_web_and_share.py create mode 100644 src/extension/bridge.ts diff --git a/ai_plans/2026-06-19_finish-self-hosted-auth-flow.md b/ai_plans/2026-06-19_finish-self-hosted-auth-flow.md new file mode 100644 index 0000000000..1ec9ddbf0c --- /dev/null +++ b/ai_plans/2026-06-19_finish-self-hosted-auth-flow.md @@ -0,0 +1,128 @@ +# Finish the self-hosted Authentik auth flow (operational + persistence) + +**Date:** 2026-06-19 +**Branch:** `feature/self-hosted-cloud-backend` +**Goal (user's words):** "We're using Authentik. Run Authentik, set up the app and +user account, integrate with the server, and ensure a user can authenticate and +**stay authenticated until logoff** even after returning next day/week." + +--- + +## What was already true (verified in code, not assumed) + +The auth _code_ is complete; the gap is operational. Evidence: + +- **Browser OAuth flow** — [browser.py](../self-hosted-cloudapi/src/routers/browser.py): + PKCE → Authentik authorize → `/auth/clerk/callback` → user/session/ticket → + HTML bounce to `vscode://`. Authentik's own access/refresh tokens are used once + for `get_userinfo` and **never stored** → extension longevity does NOT depend on + the Authentik session lasting. +- **Durable credential** — the extension trades the single-use ticket for a + **client token** (opaque, SHA-256-hashed). `ClientToken.expires_at` is nullable + and **never set** → never expires server-side + ([user.py](../self-hosted-cloudapi/src/models/user.py)). + `Session.expires_at` likewise never set; `is_active` flips to `False` only on + explicit `/v1/client/sessions/{id}/remove` (logoff). +- **Short-lived JWT** — [jwt_issuer.py](../self-hosted-cloudapi/src/auth/jwt_issuer.py) + `issue_session_token(expires_in=60)`. Re-minted on demand from the client token + via `POST /v1/client/sessions/{id}/tokens`. +- **Client-side persistence** — [WebAuthService.ts:214](../packages/cloud/src/WebAuthService.ts#L214) + stores `{clientToken, sessionId}` in VS Code `SecretStorage`, reloads on + `initialize()`, and `refreshSession()` re-mints a JWT on a `RefreshTimer`. + +**Conclusion:** "return next day/week, stay logged in until logoff" is already the +design. The only missing pieces are: Authentik is down, and the API isn't running. + +## Environment findings + +- Existing Authentik lives in compose project `llm` at + `/opt/docker/llm/docker-compose.yaml`: `auth_db` (postgres:16-alpine), + `auth_server`, `auth_worker` (goauthentik 2026.2.2). All **exited ~4 weeks ago**. +- Data survived on bind mounts: `/opt/docker/llm/vol/auth/{data,postgres,certs}`. +- `/opt/docker/llm/.env` still holds `AUTHENTIK_SECRET_KEY` (so existing encrypted + DB rows stay decryptable — must NOT change it) and `AUTH_PG_PASS`. +- **Two problems with reviving as-is:** + 1. **No Redis** in the stack. Authentik requires Redis (Celery broker for the + worker, cache, Channels layer). `auth_worker` exited **1** — consistent with a + missing broker. Must add a `redis:alpine` service. + 2. **Port 5432 conflict.** `auth_db` maps host `5432:5432`, but the unrelated, + currently-running `voicebot-database` holds host 5432. +- Cloud API config: [self-hosted-cloudapi/.env](../self-hosted-cloudapi/.env) → + `DATABASE_URL=postgresql://authentik:…@localhost:5432/stork_code`, + `AUTHENTIK_BASE_URL=http://localhost:9000`, `AUTHENTIK_APP_SLUG=stork-code`, + `AUTHENTIK_CLIENT_ID=nLV79xyh…`, `AUTHENTIK_REDIRECT_URI=http://localhost:8085/auth/clerk/callback`, + `API_BASE_URL=http://localhost:8085`. So the cloud API uses a **second database + `stork_code`** on the same Authentik Postgres, connecting as user `authentik`. +- Free host ports: 5544, 9000, 9443, 6379, 8085. + +## Decisions (from the user) + +1. **Revive the existing `/opt/docker/llm` stack** (keep `stork-code` app + users) and + **add the missing Redis**. +2. **Remap `auth_db` host port 5432 → 5544** (zero disruption to voicebot); + point the cloud API `.env` at 5544. Authentik's internal `auth_db:5432` + connection is over the compose network and unaffected. + +## Plan + +### 1. Edit `/opt/docker/llm/docker-compose.yaml` + +- Add `auth_redis` (`redis:alpine`, healthcheck `redis-cli ping`, volume + `./vol/auth/redis:/data`, restart unless-stopped, **no host port** — internal only). +- Add `AUTHENTIK_REDIS__HOST: auth_redis` to `auth_server` and `auth_worker` env. +- Add `auth_redis` (condition: service_healthy) to `depends_on` of server + worker. +- Change `auth_db` host port `"5432:5432"` → `"5544:5432"`. + +### 2. Bring up the auth stack + +`docker compose -f /opt/docker/llm/docker-compose.yaml up -d auth_db auth_redis auth_server auth_worker` + +- Verify `auth_db` healthy, `auth_worker` stays up (no exit 1), `auth_server` logs + show "Starting authentik server", `GET http://localhost:9000/-/health/ready/` → 200. + +### 3. Verify/restore Authentik config + +- Confirm an OAuth2 **Provider** exists whose client ID == `AUTHENTIK_CLIENT_ID`, + with redirect URI `http://localhost:8085/auth/clerk/callback`, bound to an + **Application** slug `stork-code`. Confirm scopes include `openid email profile`. +- Confirm at least one **user** exists (or create one + set a password). +- If the provider/app didn't survive, recreate it and sync `client_id`/`secret` + into `self-hosted-cloudapi/.env`. + +### 4. Wire + migrate the cloud API DB + +- Update `self-hosted-cloudapi/.env` `DATABASE_URL` port `5432` → `5544`. +- Ensure database `stork_code` exists on the Authentik Postgres (create if missing: + `CREATE DATABASE stork_code OWNER authentik;`). +- Run `uv run alembic upgrade head`. + +### 5. Start the cloud API + +- `uv run uvicorn src.main:app --host 0.0.0.0 --port 8085` (background). +- Smoke: `GET /` → `{"status":"ok"}`; auth routes mounted. + +### 6. End-to-end + persistence verification + +- Drive the OAuth flow (curl through authorize → callback, or the extension) to a + ticket; `POST /v1/client/sign_ins` → capture client token + `created_session_id`; + `POST /v1/client/sessions/{id}/tokens` → **200 + jwt** (the line that used to 404); + `GET /v1/me` → 200. +- **Persistence proof:** with the same stored client token, re-call the tokens + endpoint (simulating "next day" after the 60 s JWT expired) → fresh jwt, no + re-login. Confirms client token + session never expire and the extension's + `RefreshTimer` path works. + +## Out of scope (tracked separately) + +- Authentik groups → `org_id` mapping ([browser.py:281](../self-hosted-cloudapi/src/routers/browser.py#L281)). + User did not ask for orgs; JWT simply omits `r.o`. Revisit only if org-scoped + features are needed. +- Anthropic streaming SSE conversion, Google/xAI providers, marketplace org + filtering, admin API — unrelated to the auth flow. + +## Risk / rollback + +- Compose edits are additive (Redis) + a host-port remap; revert the three hunks to + restore the original file. The `voicebot` project is never touched. +- `AUTHENTIK_SECRET_KEY` is left unchanged → existing encrypted data stays readable. +- Cloud API `.env` change is a single port digit; revert to 5432 if 5544 is undesired. diff --git a/ai_plans/2026-06-19_web-task-list-and-viewer.md b/ai_plans/2026-06-19_web-task-list-and-viewer.md new file mode 100644 index 0000000000..7fb47234ff --- /dev/null +++ b/ai_plans/2026-06-19_web-task-list-and-viewer.md @@ -0,0 +1,124 @@ +# Self-hosted Cloud: web task list + read-only task viewer + +**Date:** 2026-06-19 +**Branch:** `feature/self-hosted-web-task-viewer` (stacked on `feature/self-hosted-cloud-backend`) +**Goal (user's words):** "see the list of tasks on web page (after backend run) and after +click on task, we should see the same flow as in Tumble Code." + +--- + +## Context + +Auth is finished (Authentik → cloud API on `:8085`, user signed in). Next: a web page listing +the user's tasks and a read-only conversation view per task. Investigation found the +extension's "share task to cloud" pipeline is **broken end-to-end on the backend** and there +is **no web frontend**. Live DB: 1 user, `tasks=0, task_messages=0, task_shares=0`. + +**Decisions (user):** lightweight read-only renderer (not reusing the VS Code-coupled +`ChatRow.tsx`); list **shared tasks only**; server-rendered Jinja2 + minimal JS inside FastAPI +(no Node build). + +## How task data arrives (verified) + +Upload happens only on **Share** in the extension: +`ShareButton` → `shareCurrentTask` (webviewMessageHandler.ts:844) → +`CloudService.shareTask(taskId, visibility, clineMessages)` (CloudService.ts:315): + +1. `POST /api/extension/share`; on **404** `TaskNotFoundError` → +2. `POST /api/events/backfill` (multipart `task.json` = full `ClineMessage[]`, + TelemetryClient.ts:238) → retry share. + +`ClineMessage`: packages/types/src/message.ts:249. + +## Four blockers (each verified) + +- **A. Sharing disabled (button dead).** `enable_task_sharing` comes only from org settings; + no org → `cloud_settings=None` (settings_service.py:27) → client `canShareTask()` false → + Share button disabled (ShareButton.tsx:128). +- **B. Wrong status code.** `share_task` returns HTTP 200 `{success:false}` (share_service.py:25) + but client backfills only on HTTP 404 (CloudAPI.ts:97). First share silently fails. +- **C. No `Task` row.** `backfill_messages` inserts `TaskMessage` (FK → `tasks.id`) but never + creates the parent `Task` → IntegrityError. FK `task_messages_task_id_fkey` confirmed live. +- **D. No web UI.** `share_url` = relative `/shared/{id}`, unserved; no list endpoint; auth is + Bearer-JWT only (dependencies.py:15) — browser needs a cookie session. + +## Implementation + +### 1. Backend fixes (no extension changes) + +- **A** `settings_service.py`: org-less → return `OrganizationCloudSettings(enable_task_sharing= +True, allow_public_task_sharing=True)`. New `enable_task_sharing: bool = True` in + `config/settings.py` (env `ENABLE_TASK_SHARING`). + - **A.2 (follow-up, 2026-06-20):** the Share button stayed disabled after A even with the + correct settings live. Root cause: the extension caches org settings and only replaces them + when `version` changes (`CloudSettingsService.fetchSettings`, version check at + CloudSettingsService.ts:139). The org-less response hardcoded `version = 0`; the client had + cached `version:0, cloudSettings:null` at the login _before_ A, so the new (still `0`) + response was rejected as unchanged. Fix: org-less `version` is now content-derived + (`_content_version` = sha256 of the cloud-settings payload → 32-bit int), so it differs from + the stale `0` and auto-bumps on any future toggle. Client re-fetches hourly + on session + start, so a window reload (or sign-out/in) applies it immediately. +- **B** `routers/extension.py` share: raise `HTTPException(404)` when task missing. +- **C** `services/telemetry_service.py` `backfill_messages(user_id, …)`: get-or-create + `Task(id, user_id)`, delete existing `TaskMessage`s for the task, re-insert in order; pass + `current_user` from `routers/events.py`. +- **share_url absolute** `services/share_service.py`: `{api_base_url}/shared/{id}`, + manage `{api_base_url}/app/tasks/{id}`. + +### 2. Browser session auth — `src/auth/web_session.py` + +Reuse `generate_pkce_pair`, `get_authorize_url`, `store_oauth_state`, `get_oauth_state`, +`exchange_code_for_tokens`, `get_userinfo`, `get_or_create_user`, `create_session`. Reuse the +single `/auth/clerk/callback` redirect URI; branch on the stored `auth_redirect` marker +(`http(s)://` = web → set cookie + 302 `/app`; `vscode://` = existing bounce). Cookie +`tumble_session` = itsdangerous-signed `{session_id,user_id}`, 30-day, HttpOnly, SameSite=Lax. +`get_web_user` dependency validates the cookie + `Session.is_active`. Routes `/app/login`, +`/app/logout`. + +### 3. Web router + templates — `src/routers/web.py`, `src/web/templates/`, `src/web/static/` + +- `GET /app` task list (own tasks, newest first, derived title + counts). +- `GET /app/tasks/{id}` detail (session + ownership). +- `GET /shared/{id}` public target (anon if visibility public, else session). +- Lightweight renderer: parse each `TaskMessage.message_data` `ClineMessage`, render per + say/ask type; vendored `marked` + minimal highlight CSS; dark theme like browser.py. +- Mount `Jinja2Templates` + `StaticFiles` in `main.py`; add `jinja2` dep. + +### 4. Migration + +`tasks` already has `user_id`; add a migration only if DDL changes. Head `b2c3d4e5f6a7`. + +## Out of scope + +Auto-sync all tasks; React component reuse; Authentik group→org_id mapping. + +## Verification + +**Status: implemented & verified (2026-06-19).** + +Automated (`tests/test_web_and_share.py`, 9 new tests; full suite **29 passed**): + +- B: `POST /api/extension/share` for an unknown task → **404**. +- C: `POST /api/events/backfill` creates the `Task` row + 3 `TaskMessage`s; re-share with a + shorter set **replaces** (count 1, still 1 Task) — idempotent. +- Web: `/app` without session → **303** `/app/login`; `/app` with session lists owned tasks + (derived title rendered); `/app/tasks/{id}` for a non-owner → **404**; `/shared/{id}` + public → 200 anon, private → 303 login, unknown → 404. + +Live smoke (uvicorn on throwaway sqlite): + +- `/health` 200; `/app` → 303 `/app/login`; `/app/login` → 307 to Authentik authorize URL + (PKCE + state present). +- `/static/app.css` 200 `text/css`; `/static/render.js` 200 `text/javascript`; + vendored `marked.min.js` (35479 B) + `purify.min.js` (21496 B) served. +- A: `get_extension_settings(org_id=None)` → + `cloudSettings.enableTaskSharing == true`, `allowPublicTaskSharing == true`. + +Remaining manual step (needs the real extension + Authentik, not scriptable here): +sign in to the extension, run a small task, click **Share**, confirm the live log shows +`share 404 → backfill 200 → share 200` and `/app` lists it, `/shared/{id}` renders it. + +## Risk / rollback + +Backend changes are additive + one status-code change; revert files to restore. Web routes are +new and isolated. No `AUTHENTIK_SECRET_KEY` change. New cookie uses existing `secret_key`. diff --git a/ai_plans/2026-06-20_fix-share-button-disabled-null-settings.md b/ai_plans/2026-06-20_fix-share-button-disabled-null-settings.md new file mode 100644 index 0000000000..2aadf57695 --- /dev/null +++ b/ai_plans/2026-06-20_fix-share-button-disabled-null-settings.md @@ -0,0 +1,91 @@ +# Fix: Share button stays disabled — extension-settings emits JSON `null`, client Zod rejects it + +Date: 2026-06-20 +Branch: feature/self-hosted-web-task-viewer +Area: self-hosted-cloudapi (settings serialization) + +## Symptom + +In the extension Task header the Share (Share2) icon is rendered **disabled** (greyed +out, not clickable). Tooltip would read "sharingDisabledByOrganization". + +## Root cause (proven with evidence) + +The Share button is disabled in exactly one branch of `ShareButton.tsx`: + +``` +cloudIsAuthenticated && !sharingEnabled -> disabled +``` + +`sharingEnabled` comes from `CloudService.canShareTask()`, which returns +`settingsService.getSettings()?.cloudSettings?.enableTaskSharing`. That value is +populated by `CloudSettingsService.fetchSettings()` from `GET /api/extension-settings`. + +Evidence collected: + +1. **Backend returns the right value over HTTP (200):** for the real user + `user_2c8fdf212b024808aa7a1ba1a`, `organization.cloudSettings.enableTaskSharing` + is `true`. So the data is correct. + +2. **But the client never stores it.** The extension's VS Code `globalState` + (`QUB-IT.tumble-code` → key `organization-settings`) is **`null`**. The fetch + never populated the cache. + +3. **Why the cache is null — schema parse fails.** The backend (Pydantic) serializes + _unset_ `Optional` fields as JSON `null`: + `features:null, hiddenMcps:null, hideMarketplaceMcps:null, mcps:null, +providerProfiles:null, cloudSettings.recordTaskMessages:null, ...` and on the user + side `settings.taskSyncEnabled:null`. + + The client schemas (`packages/types/src/cloud.ts`) declare these as `.optional()`, + which accepts `undefined` but **rejects `null`**. Running the real + `organizationSettingsSchema` / `userSettingsDataSchema` against the live response: + + ``` + ORG parse success: false + cloudSettings.recordTaskMessages: Expected boolean, received null + features: Expected object, received null + hiddenMcps: Expected array, received null + ... (10 issues) + USER parse success: false + settings.taskSyncEnabled: Expected boolean, received null + ``` + + `parseExtensionSettingsResponse` therefore returns `{success:false}`, + `fetchSettings()` logs "Invalid extension settings format" and returns without + assigning `this.settings`. Cache stays `null` → `canShareTask()` → `false` → + button disabled. + +4. **Fix verified:** stripping nulls (what `exclude_none=True` does) makes both + schemas parse, and `enableTaskSharing === true`. + +This is a backend contract bug: "optional" in the client means _may be absent_, not +_may be explicit null_. The backend must omit unset optionals rather than emit `null`. + +## Fix + +Serialize the settings responses with nulls omitted. Add +`response_model_exclude_none=True` to both routes in +`self-hosted-cloudapi/src/routers/settings.py`: + +- `GET /api/extension-settings` +- `PATCH /api/user-settings` (same model family, parsed by the same strict client + schema in `CloudSettingsService.updateUserSettings`) + +`exclude_none` only drops `null` values; required non-null fields (`version`, +`defaultSettings: {}`, `allowList`, `cloudSettings.enableTaskSharing: true`, +`features: {}`) are preserved. + +## Post-fix activation + +The running uvicorn has no `--reload`, and the client already cached `null`: + +1. Restart the backend so the new serialization takes effect. +2. Reload the extension host window (or sign out/in). On the next fetch the response + parses, `organization-settings` is cached, and the Share button enables. + +## Tests + +Extend `self-hosted-cloudapi/tests/test_web_and_share.py` (or settings tests) to +assert the `/api/extension-settings` response contains **no `null` values** at any +nesting level, and that `organization.cloudSettings.enableTaskSharing` is present. diff --git a/ai_plans/2026-06-20_remote-task-control.md b/ai_plans/2026-06-20_remote-task-control.md new file mode 100644 index 0000000000..8c8a4642cf --- /dev/null +++ b/ai_plans/2026-06-20_remote-task-control.md @@ -0,0 +1,158 @@ +# Remote live control of a Tumble Code task from the web (socket.io bridge) + +**Date:** 2026-06-20 +**Branch:** `feature/self-hosted-remote-task-control` (stacked on `feature/self-hosted-web-task-viewer`) +**Goal (user's words):** "I need to be able to remotely converse with [a] session. I want the +input as in vscode, and be able to allow/disallow requests. Also stop task and steer the +auto-approves (detailed (read, mcp, subtasks, write, mode, execute) and main modes (default, +bypass and autonomous)). Also I want to see the tokens in/out, context length (just like in +vscode)." + +--- + +## Context + +Tasks already reach the self-hosted cloud backend as a **read-only snapshot** (Share pipeline → +`Task`/`TaskMessage` rows → server-rendered `/app/tasks/{id}` viewer, see +`ai_plans/2026-06-19_web-task-list-and-viewer.md`). This adds a **live control channel** so the web +page can drive a running task like the VS Code panel: input, approve/deny, stop, auto-approve +steering (6 detailed toggles + `default`/`bypass`/`autonomous` mode), and live tokens/context. + +**Hard constraint (user):** all traffic is **Tumble Code ↔ backend ↔ browser**. Never a direct +VS Code ↔ browser connection — the backend always relays. + +**Decisions (user):** + +- **Transport: socket.io bridge** (upstream "Roomote" architecture). +- **Connection model: opt-in** — a setting gates whether the extension connects/registers. +- **Scope: active task + resume from history.** + +The upstream bridge **protocol enums/schemas already exist** in `packages/types/src/cloud.ts` +(`ExtensionSocketEvents`, `TaskSocketEvents`, `TaskBridgeEvent`, `TaskBridgeCommand`, +`ExtensionInstance`) but the **implementation was never ported** (no `socket.io-client`, no +orchestrator, no backend socket.io server). `services/bridge_service.py` is a stub returning a dead +`ws://localhost:8080/ws`. + +## Architecture (verified entry points) + +``` + VS Code (Tumble Code) FastAPI cloud API (:8085) Browser (/app/tasks/{id}) + BridgeOrchestrator ── socket.io ──► python-socketio AsyncServer ◄── socket.io ── task page JS + attaches to API event bus (mounted on the app) cookie-auth handshake + reaches ClineProvider per-user + per-task rooms live render + controls + relays event ⇄ command + persists Message events → TaskMessage +``` + +Both sides authenticate to the **same `user_id`** (extension = bridge JWT, browser = +`tumble_session` cookie), which is how the server pairs a browser to that user's extension and +authorizes task access. + +### Extension control surface (verified, reused as-is) + +- inject input → `Task.submitUserMessage(text, images, mode?, providerProfile?)` (Task.ts:952) +- approve/deny → `task.handleWebviewAskResponse("yesButtonClicked"|"noButtonClicked"|"messageResponse", text, images)` (Task.ts:916) +- stop → `provider.cancelTask()` (webviewMessageHandler.ts:1327) +- auto-approve → `provider.contextProxy.setValue(key, val)` + `provider.postStateToWebview()`; + keys: `autoApprovalEnabled`, `autoApprovalMode` (`default|bypass|autonomous`, global-settings.ts:40), + `alwaysAllowReadOnly|Write|Execute|Mcp|ModeSwitch|Subtasks` +- resume → `provider.showTaskWithId(id)` (ClineProvider.ts:1848) +- live bus → `API` re-emits `Message`/`TaskModeSwitched`/`TaskAskResponded`/`TaskTokenUsageUpdated` + (api.ts:378); numbers via `Task.getTokenUsage()` (Task.ts:1267) + +## Implementation + +### 1. Protocol — `packages/types/src/cloud.ts` + +Extend `TaskBridgeCommand` union: `stop_task {taskId}`, `set_auto_approval {payload:{autoApprovalEnabled?, +autoApprovalMode?, alwaysAllow*?}}`, `resume_task {taskId}`. Add an `instance_state` bridge event +carrying `{mode, autoApproval snapshot, tokenUsage, contextTokens, contextWindow, currentAsk?}`. + +### 2. Backend — socket.io on FastAPI + +- Dep `python-socketio`. `src/realtime/sio.py`: `AsyncServer(async_mode="asgi")`; mount in `main.py`. +- Handshake auth: extension via `decode_token` on `auth["token"]`; browser via `tumble_session` + cookie (reuse `web_session.py`). Join `user:{user_id}`. +- Events: `extension:register`/`heartbeat` → in-memory registry; `task:join` → DB ownership check; + `task:command` (browser) → relay to that user's extension as `task:relayed_command`; + `extension:event` → relay to `task:{id}` room + upsert Message into `TaskMessage` + (reuse `telemetry_service.backfill_messages` logic). +- Rewire `bridge_service.py` + `/api/extension/bridge/config` to real `socketBridgeUrl` + bridge JWT; + `bridge_enabled=True` default. + +### 3. Extension — bridge client + +- Dep `socket.io-client` in `packages/cloud`. New `packages/cloud/src/bridge/BridgeOrchestrator.ts`: + connect with token from `CloudAPI.bridgeConfig()`, `extension:register`, heartbeat, forward + API-bus events, dispatch `task:relayed_command` → control entry points. +- Wire in `src/extension.ts` (~388) with `API` bus + `provider` + `cloudService`. Gate on opt-in + setting `tumble-code.remoteControlEnabled` (default false) + `remoteControlEnabled` global setting. + +### 4. Web — interactive `/app/tasks/{id}` + +- Vendor `socket.io-client` min.js. Extend `task_detail.html` + `render.js`: `task:join`, append + relayed Message events, UI → `task:command` (chat input, Approve/Deny, Stop, auto-approve bar with + mode selector + 6 toggles, token/context header, Resume button). Offline/live states. + +### 5. Migration + +None — reuses `Task`/`TaskMessage`; instance registry is in-memory. + +## Out of scope + +Multi-instance disambiguation beyond newest-per-user; web image paste; visibility changes; upstream +merge reconciliation of the extended schema. + +## Verification + +- Backend `tests/test_bridge.py` (python-socketio AsyncSimpleClient): register on JWT; `task:join` + only owned tasks; `task:command` relayed only to that user's extension; Message event upserts + `TaskMessage`. Full `uv run pytest`. +- Extension vitest: command dispatcher mapping; orchestrator connects only when setting on. +- Manual e2e (real Authentik + extension): drive a task from the web — input, approve/deny, mode + flip reflected in VS Code, live tokens, stop, resume. + +## Risk / rollback + +Additive; existing HTTP routes/viewer/Share untouched. Disable via `bridge_enabled=False` / +`remoteControlEnabled=false`. Relay strictly per-`user_id`; `task:join` DB-ownership-checked; bridge +JWT short-lived. No browser↔VS Code direct path. + +## Status — 2026-06-20: COMPLETE (pending manual e2e) + +All five implementation phases landed and verified: + +- **Protocol** — `packages/types/src/cloud.ts`: extended `TaskBridgeCommand` with + `stop_task` / `set_auto_approval` / `resume_task`; added `instance_state` event payload. +- **Backend** — `python-socketio` AsyncServer mounted as ASGI sub-app at `/bridge` + (not wrapping `app`, to preserve `dependency_overrides`); handshake auth (extension + JWT + browser `tumble_session` cookie → same `user_id`); in-memory instance registry + (newest-wins per user); `task:join` DB ownership check; `task:command`→extension relay; + `task:event` Message→`task:{id}` room relay + idempotent `TaskMessage` upsert by ts. + `bridge_service`/`/api/extension/bridge/config` rewired; `bridge_enabled`/`bridge_path` settings. +- **Extension** — `@roo-code/cloud` `BridgeOrchestrator` + `dispatchBridgeCommand` (structural + `BridgeProvider`/`BridgeTask` interfaces, no runtime dep on host `src/`); `socket.io-client` + dep; `src/extension/bridge.ts` adapter wired in `extension.ts`, gated on opt-in + `tumble-code.remoteControlEnabled` (default false) + cloud auth; reconciles on toggle/sign-in. +- **Web** — `task_detail.html` `{% if live %}` surface (header: tokens in/out, context n/window, + cost, mode; ask-bar Approve/Deny; auto-approve bar: enabled + mode select + 6 toggles; chat + input + Send/Stop/Resume); `render.js` refactored into a `mountConversation` upsert-by-ts + controller; `live.js` socket controller; vendored `socket.io.min.js` (v4.8.3). Owner route live + (gated on `bridge_enabled`), `/shared` always read-only. + +### Verification + +- Backend: `uv run pytest` → **51 passed** (18 in `test_bridge.py`; 2 new web live-control + regression tests asserting owner page ships `#live-controls`/`#live-config`/`live.js` and + `/shared` never does, even with the bridge enabled). +- Extension: `@roo-code/cloud` vitest → 278 passed (incl. 14 bridge tests); tsc + eslint + (`--max-warnings=0`) clean for the bridge sources. +- **Pending: manual e2e** (needs real Authentik + extension) — enable the setting, drive a task + from the web (message, Approve/Deny, flip auto-approve, watch tokens/context, Stop, Resume). + +### 2026-06-21: bridge ungated at the backend + +`BRIDGE_ENABLED=true` is now the shipped default (`.env` + `.env.example`); the socket.io relay is +always mounted and the owner web page is always live. The **extension** still requires the opt-in +`tumble-code.remoteControlEnabled` toggle AND an authenticated cloud session before it connects — +remote control of the dev machine stays user-initiated. diff --git a/ai_plans/2026-06-21_delete-shared-task-from-cloud.md b/ai_plans/2026-06-21_delete-shared-task-from-cloud.md new file mode 100644 index 0000000000..2c0e224fb1 --- /dev/null +++ b/ai_plans/2026-06-21_delete-shared-task-from-cloud.md @@ -0,0 +1,66 @@ +# Delete shared task from the cloud backend (remove from DB) + +**Date:** 2026-06-21 +**Branch:** feature/self-hosted-remote-task-control + +## Goal + +Let a task owner permanently remove a shared task from the self-hosted cloud +backend — deleting the `Task` row and everything hanging off it (conversation +messages + share link) so it disappears from the web viewer and the `/shared` +link 404s. + +## Decision (asked & confirmed) + +Full task delete, surfaced in the **web UI only** (the `/app` task list and the +owner task-detail page). No extension-side API in this change. + +## Why a full delete is clean + +[`models/task.py`](../self-hosted-cloudapi/src/models/task.py): `Task.messages` +and `Task.shares` both use `cascade="all, delete-orphan"` with DB-level +`ondelete="CASCADE"`. "Remove from DB" therefore maps to deleting the `Task`. +To stay safe under async SQLAlchemy (no lazy-load of children), we delete +children explicitly, children-first, rather than relying on ORM cascade +triggering a lazy load. + +## Changes + +1. **`services/share_service.py`** — add + `delete_shared_task(db, task_id, user_id) -> bool`: + + - Load the `Task`; return `False` if missing or not owned by `user_id` + (so a non-owner / unknown id is a no-op, never another user's data). + - Explicit `delete()` of `TaskMessage`, then `TaskShare`, then `Task`. + - Return `True` on delete. `get_db` commits on success. + +2. **`routers/web.py`** — add `POST /app/tasks/{task_id}/delete`: + + - Owner cookie session required (redirect to `/app/login` if anon). + - Call `delete_shared_task`; redirect 303 → `/app` regardless (idempotent). + - Add `can_delete` to the detail template context: `True` on the owner + `/app/tasks/{id}` page, `is_owner` on `/shared/{id}` (owner viewing their + own share link can delete; anon/non-owner cannot). + +3. **Templates** + + - `tasks_list.html` — each row becomes `link + delete form`; the form POSTs + to the delete route with a JS `confirm()` guard. + - `task_detail.html` — a "Delete task" button (same form + confirm) in the + header, gated on `can_delete`. + +4. **`web/static/app.css`** — `.btn-delete` style (muted, red on hover) and a + small `.task-item` flex tweak so the delete button sits beside the link. + +## CSRF / safety + +Session cookie is `samesite=lax`, so cross-site POSTs don't carry it — a +same-origin form POST from our own page is the only thing that authenticates. +Plus a `confirm()` dialog. Adequate for this self-hosted app. + +## Tests (`tests/test_web_and_share.py`) + +- delete removes the Task + its messages + its share rows (owner). +- non-owner POST is a no-op: the task and its data survive. +- unauthenticated POST redirects to `/app/login`. +- `/shared/{id}` 404s after the owner deletes the task. diff --git a/ai_plans/2026-06-21_fix-bridge-config-section-undefined-logout.md b/ai_plans/2026-06-21_fix-bridge-config-section-undefined-logout.md new file mode 100644 index 0000000000..73fad13490 --- /dev/null +++ b/ai_plans/2026-06-21_fix-bridge-config-section-undefined-logout.md @@ -0,0 +1,74 @@ +# Fix: backend restart forces VS Code re-auth (`CONFIG_SECTION is not defined`) + +**Date:** 2026-06-21 +**Branch:** `feature/self-hosted-remote-task-control` (the branch that introduced the bridge — this is the bridge's own bug) + +## Symptom (user's words) + +> "Whenever I restart the backend I need to re-authenticate vscode Tumble code or I +> can't share the task and get: `[TelemetryClient#fetch] Unauthorized: No session +token available.`" + +## Root cause (proven with evidence, not assumed) + +The extension's own log (`~/.config/Code/logs/.../1-Tumble-Code.log`) shows the exact +chain: + +``` +[bridge] Failed to set up remote control bridge: CONFIG_SECTION is not defined +[auth] changeState: attempting-session -> active-session +[auth] Failed to refresh session +ReferenceError: CONFIG_SECTION is not defined + at n (extension.js) ← isEnabled() + at authStateListener ← bridge reconcile() registered on auth-state-changed + at changeState + at refreshSession +[auth] changeState: active-session -> logged-out +``` + +`src/extension/bridge.ts` references `CONFIG_SECTION` and `CONFIG_KEY` +(lines 35 and 120) but **never defines or imports them**. Every call to +`isEnabled()` throws `ReferenceError`. + +`reconcile()` calls `isEnabled()`, and `reconcile` is registered as the +`auth-state-changed` listener (bridge.ts:126). The cloud `WebAuthService` emits +`auth-state-changed` **synchronously** from inside `changeState()`, which runs +inside `refreshSession()`. So when a backend restart makes the refresh timer +re-mint a session token and flip the state to `active-session`, the synchronous +emit invokes the throwing `reconcile`, the `ReferenceError` propagates up through +`emit → changeState → refreshSession`, corrupts the auth state machine, and the +session ends up `logged-out`. Result: **every backend restart logs the user out**, +and `getSessionToken()` then returns nothing → `TelemetryClient` logs +"No session token available" and Share fails. + +### Ruled out (with evidence) + +- **Server-side token persistence** — NOT the cause. Client tokens live in + persistent postgres (`stork_code`), never expire (`ClientToken.expires_at` NULL), + `get_db()` commits. Minted a real client token and hit + `POST /v1/client/sessions/{id}/tokens` → 200 + JWT. Hammered that endpoint with + the same token across a `--reload` cycle → **40/40 returned 200**, zero blips. + The backend recovers seamlessly; the bug is entirely client-side. + +## Fix + +In `src/extension/bridge.ts`: + +1. `import { Package } from "../shared/package"` and define the missing constants: + `const CONFIG_SECTION = Package.name` (`"tumble-code"`), + `const CONFIG_KEY = "remoteControlEnabled"`. Matches the existing setting + `tumble-code.remoteControlEnabled` in `src/package.json` and the + `getConfiguration(Package.name)` idiom at `src/extension.ts:184`. +2. Harden `reconcile()` to catch and log its own errors, so a future bridge fault + can never again propagate synchronously into the auth state machine. + +## Verification + +- `isEnabled()` no longer throws; `reconcile` runs cleanly on auth-state changes. +- Rebuild the extension; confirm the startup log no longer shows + `[bridge] Failed to set up remote control bridge: CONFIG_SECTION is not defined`, + and that a backend restart no longer flips auth to `logged-out`. + +``` + +``` diff --git a/ai_plans/2026-06-21_fix-bridge-socketio-mount-404.md b/ai_plans/2026-06-21_fix-bridge-socketio-mount-404.md new file mode 100644 index 0000000000..513fd66d1d --- /dev/null +++ b/ai_plans/2026-06-21_fix-bridge-socketio-mount-404.md @@ -0,0 +1,62 @@ +# Fix: socket.io bridge WebSocket handshake returns 404 (ASGI RuntimeError) + +## Symptom + +On every bridge WebSocket connection the server logs: + +``` +RuntimeError: Expected ASGI message 'websocket.accept', 'websocket.close', +or 'websocket.http.response.start' but got 'http.response.start'. +``` + +The connection is opened then immediately closed; the handshake never reaches +the socket.io `connect` handler. + +## Root cause (verified) + +`src/main.py` mounts the relay as a Starlette sub-app: + +```python +app.mount("/bridge", socketio.ASGIApp(sio, socketio_path="socket.io")) +``` + +`socketio.ASGIApp(..., socketio_path="socket.io")` configures engine.io's +`ASGIApp` with `engineio_path = "/socket.io/"`. engine.io decides whether a +request belongs to it by testing the **raw** `scope["path"]`: + +```python +self._ensure_trailing_slash(scope['path']).startswith(self.engineio_path) +``` + +It does NOT account for ASGI `root_path`. + +In Starlette 0.50.0 (`routing.py` `Mount.matches`), mounting a sub-app no longer +rewrites `scope["path"]` — it only appends the prefix to `root_path` and leaves +`scope["path"]` as the full original path. So the client request to +`/bridge/socket.io/?EIO=4&transport=websocket` arrives at the sub-app with +`scope["path"] == "/bridge/socket.io/"`, which does **not** start with +`/socket.io/`. engine.io treats it as unrelated traffic and calls `not_found()`, +which emits an HTTP `http.response.start` (404) onto a WebSocket connection — +uvicorn rejects that with the RuntimeError above. + +(Confirmed: client connects with `path: "/bridge/socket.io"` in +`packages/cloud/src/bridge/BridgeOrchestrator.ts:77`.) + +## Fix + +Tell engine.io its full public path so it matches the un-stripped +`scope["path"]`. Mount stays at `/bridge` (keeps `app` a FastAPI instance, so +tests' `app.dependency_overrides` keep working): + +```python +app.mount("/bridge", socketio.ASGIApp(sio, socketio_path="bridge/socket.io")) +``` + +`socketio_path="bridge/socket.io"` → `engineio_path = "/bridge/socket.io/"`, +which `/bridge/socket.io/...` starts with. Client path is unchanged. + +## Verification + +- Restart uvicorn; connect the extension bridge → handshake reaches `connect`, + no RuntimeError, `connection open` stays open. +- `tests/test_bridge.py` still passes. diff --git a/ai_plans/2026-06-21_fix-extension-offline-on-shared-task.md b/ai_plans/2026-06-21_fix-extension-offline-on-shared-task.md new file mode 100644 index 0000000000..126a128f0e --- /dev/null +++ b/ai_plans/2026-06-21_fix-extension-offline-on-shared-task.md @@ -0,0 +1,85 @@ +# Fix: shared task shows "Extension offline" — make it remote-controllable right after sharing + +**Date:** 2026-06-21 +**Branch:** `feature/self-hosted-remote-task-control` +**User's words:** "all the same with 'Extension offline' on shared task. Fix it. Once user +share the task, it should be already remote-controllable." + +## Symptom + +After sharing a task, the web cockpit shows the live-status pill **"Extension offline"** and +all controls are disabled. The user expects that the moment a task is shared, the page is +already drivable from the browser. + +## Root cause (proven with evidence) + +`registry.has_extension(user_id)` is what the live page reports as `instanceOnline` +(`src/realtime/sio.py:212`, surfaced by `live.js` as `"Extension offline"`). It is true only +once the extension's `BridgeOrchestrator` has connected and emitted `extension:register`. + +The orchestrator never starts: + +- `src/extension/bridge.ts` gates `start()` behind + `isEnabled() = getConfiguration("tumble-code").get("remoteControlEnabled", false)`. +- `src/package.json` declares `tumble-code.remoteControlEnabled` with **`default: false`**. +- The user's `~/.config/Code/User/settings.json` does **not** set the key → it resolves to + **false**. Verified: `grep remoteControlEnabled settings.json` → no match. +- Therefore `reconcile()` always calls `stop()` (a no-op), the socket never connects, the + extension never registers, and every live page renders "Extension offline". + +The installed build (`qub-it.tumble-code-3.53.0/dist/extension.js`) **does** contain the bridge +code (grep hit), so this is purely the default-off gate, not missing code. The bridge's own +header comment already claims it is _"Always on — the bridge connects whenever a cloud session +is active, with no opt-in toggle"_ — the code contradicts its documented intent. + +Second gap: the URL the user actually lands on after sharing is `/shared/{id}` +(`shareUrl` in the share response), and `routers/web.py` hard-codes that page to `live=False`. +So even with the extension online, the share link the user opens is read-only. + +## Fix + +Two coordinated changes; security boundary preserved (a _public_ share link viewed by a +stranger must never gain control). + +### 1. Extension — bind the bridge to the cloud session (no setting) + +The setting added nothing: the bridge already requires an authenticated cloud session for its +token + user identity, so a separate enable flag is pure redundancy. **Removed entirely** rather +than defaulted-on: + +- `src/package.json` + `src/package.nls.json`: delete the `tumble-code.remoteControlEnabled` + contribution and its description string. +- `src/extension/bridge.ts`: drop `isEnabled()`/`CONFIG_*` and the `onDidChangeConfiguration` + listener; `reconcile()` now follows auth state — `start()` when + `CloudService.isAuthenticated()`, `stop()` otherwise; runs on `auth-state-changed`. + +Result: whenever a cloud session is active (which it must be to share at all), the orchestrator +connects and registers, so the extension is online before the user opens the page — with no +toggle to find. + +### 2. Web — the owner's own shared link is live + +- `routers/web.py` `/shared/{task_id}`: load the `Task`, and set + `live = settings.bridge_enabled and user is not None and user["user_id"] == task.user_id`. + Anonymous / non-owner viewers stay `live=False` (read-only) exactly as before. + Pass a real `live_config_json` when live. +- `task_detail.html`: show the "Shared link · read-only" note only `{% if share_url and not +live %}` (owner driving their own shared task isn't read-only). + +The backend already independently authorizes control: `task:join` does a DB ownership check and +`task:command` relays only to that same `user_id`'s own extension. The web gating is the UI half +of the same owner-only rule. + +## Verification + +- Backend `uv run pytest`: existing `test_shared_page_never_renders_live_controls` + (anonymous viewer) still passes; add `test_shared_owner_gets_live_controls` (owner session → + ships `#live-controls` + `live.js`) and `test_shared_nonowner_stays_readonly`. +- Manual e2e: with the rebuilt extension signed in, share a task, open the share URL → status + pill flips to **Live**, controls enabled, drive the task from the browser. + +## Risk / rollback + +Additive + a default flip. Disable globally via backend `BRIDGE_ENABLED=false`, or per-machine +by setting `tumble-code.remoteControlEnabled: false`. No browser↔VS Code direct path; relay +stays strictly per-`user_id` and DB-ownership-checked. diff --git a/ai_plans/2026-06-21_fix-finished-task-empty-summary-bar.md b/ai_plans/2026-06-21_fix-finished-task-empty-summary-bar.md new file mode 100644 index 0000000000..6439b9b400 --- /dev/null +++ b/ai_plans/2026-06-21_fix-finished-task-empty-summary-bar.md @@ -0,0 +1,89 @@ +# Fix: finished task shows an empty live summary bar (tokens / context / cost = "—") + +Date: 2026-06-21 +Branch: feature/self-hosted-remote-task-control + +## Symptom + +Owner opens a task that has already finished in the web cockpit. The header pill +shows **LIVE** (extension is online) but every stat is blank: + +``` +LIVE mode — tokens — in / — out context — cost — +``` + +## Root cause (traced, not guessed) + +The header fields (`#hdr-mode`, `#hdr-tokens-in/out`, `#hdr-context`, `#hdr-cost`) +are populated **exclusively** by `applyInstanceState()` in +`self-hosted-cloudapi/src/web/static/live.js`. That runs only when a live +`instanceState` snapshot arrives: + +1. on `task:join` if the registry has a cached `instance`, or +2. on a relayed `task:relayed_event` of type `instanceState`. + +The cached instance (`src/realtime/hub.py`) is **per-user, in-memory** and: + +- is initialised to just `{lastHeartbeat}` on `register_extension` — the + extension's `register()` (`packages/cloud/src/bridge/BridgeOrchestrator.ts`) + sends no token/mode payload; +- is updated only when the extension pushes `instanceState`, which it does **only** + on `TaskModeSwitched`, `TaskTokenUsageUpdated`, `TaskAskResponded`, + `TaskInteractive`; +- is **wiped on extension disconnect** (`hub.py` `detach()`), so any reconnect + (server restart, network blip, new VS Code window) clears it; +- even when present, `snapshot()` reads `provider.getCurrentTask()` — the task + _currently active_ in VS Code, not the finished task being viewed. + +For a **finished** task none of those state events fire, so the header never gets +data. But every number it needs is already embedded in the page: each persisted +`api_req_started` message carries `tokensIn`, `tokensOut`, `cost` +(`render.js` `apiReq()`), and the canonical aggregation is +`packages/core/src/message-utils/consolidateTokenUsage.ts`: + +- totals = sum of `tokensIn` / `tokensOut` / `cost` over `api_req_started` + (+ `condense_context.cost`); +- `contextTokens` = `tokensIn + tokensOut` of the **last** `api_req_started` + (or `condense_context.newContextTokens`). + +The header simply never reads the conversation it is sitting next to. + +## Fix + +Derive tokens/cost/context from the persisted messages as a baseline, and keep +them fresh from the live message stream; let live `instanceState` override while +the task is actually running. + +1. `render.js` — `mountConversation`: + + - keep the latest raw message per `ts` (`rawByTs`); + - add `getMetrics()` mirroring `consolidateTokenUsage` + (totals + last-request `contextTokens`); + - expose `getMetrics` on the returned conversation object. + +2. `live.js`: + - add `applyMetrics()` that fills tokens/out, cost, context from + `convo.getMetrics()`, gated by `haveLiveTokens` so it never fights a live + `instanceState`; + - track `lastContextWindow` (live-only) to render the "/ window" suffix when known; + - in `applyInstanceState`, set `haveLiveTokens = true` once a snapshot carries + token data, and capture `contextWindow`; + - call `applyMetrics()` on init (baseline for finished/offline tasks) and after + each relayed `message` event (keeps totals live from the message stream). + +`mode` and `contextWindow` stay live-only — they are genuinely not in the +persisted conversation, so a finished task shows `mode —` (honest) rather than a +fabricated value. + +## Scope / non-goals + +- No backend change. The persisted messages already contain everything needed. +- Not changing the registry lifecycle (per-user snapshot, wipe-on-disconnect) — + that is correct for the _live control_ path; this fix just stops the header from + depending on it for _historical_ totals. + +## Verification + +- Open a finished owned task → tokens/context/cost populate from history; mode `—`. +- Run/resume a task → live `instanceState` token updates take over (unchanged). +- Read-only `/shared` view is unaffected (header block is `{% if live %}` only). diff --git a/ai_plans/2026-06-21_fix-reasoning-cut-stuck-early-partial-upsert.md b/ai_plans/2026-06-21_fix-reasoning-cut-stuck-early-partial-upsert.md new file mode 100644 index 0000000000..2581a6802b --- /dev/null +++ b/ai_plans/2026-06-21_fix-reasoning-cut-stuck-early-partial-upsert.md @@ -0,0 +1,101 @@ +# Fix truncated/cut conversation blocks on the web task view (reasoning shows only the first partial) + +**Date:** 2026-06-21 +**Branch:** `feature/self-hosted-remote-task-control` +**Symptom (user's words):** "After clicking reasoning block — I want to see it whole but the content is +cut. Fix it everywhere, where content is cut when user expands block." + +The screenshot is the self-hosted **web task viewer** (`self-hosted-cloudapi/src/web`): a 💭 REASONING +block, expanded, whose body is just `The user says` — the first few words of a 5.2 s reasoning trace. + +--- + +## Root cause (proven against the live DB, not assumed) + +The renderer does **not** clip: `render.js` renders `md(m.text)` into `.msg-body` with no `max-height`, +`line-clamp`, or `overflow` cap (`app.css`), and `
`/`.msg` grow freely. `md()` never truncates. +So the body literally contains only the text that was **persisted**. The cut is in the data. + +Queried live Postgres `stork_code.task_messages` directly: + +``` +created 2026-06-21 10:43:40 ts 1782038620519 partial True len 13 "The user says" <-- the screenshot +created 2026-06-21 10:43:27 ts 1782038606981 partial True len 38 "The user wants a summary of the recent" +created 2026-06-21 10:36:33 ts 1782038193126 partial False len 161 "We need to see the rest of the output..." +created 2026-06-21 10:36:10 ts 1782038170129 partial True len 16 +``` + +- `alembic_version = d4e5f6a7b8c9` — the unique-ts migration **has** run. +- **0 duplicate `(task_id, message_ts)` groups**; `uq_task_messages_task_ts` present. So the + `ON CONFLICT DO UPDATE` upsert is live and correctly collapses to one row per ts. +- **Yet** reasoning rows are frozen at short, early partials (`len 13`, `partial:true`) — the fuller + partials and the `partial:false` finalize never won. One row (`len 161`) _did_ finalize. The outcome + is **non-deterministic** across messages. + +Non-determinism is the signature of a **commit-order race**. `upsert_task_message()` runs +`INSERT … ON CONFLICT (task_id, message_ts) DO UPDATE SET message_data = EXCLUDED.message_data` +**unconditionally**, each bridge event in its own `async_session_factory()` session. Streaming reasoning +emits many `say("reasoning", , partial=true)` Message events back-to-back +(`TaskStreamProcessor.ts:186-195` — text accumulates, full value sent each chunk), plus a final +`partial:false`. python-socketio dispatches each `on_task_event` as a concurrent asyncio task; the +concurrent `DO UPDATE`s serialize on the unique-index row lock, and **whichever transaction commits last +wins** — which is non-deterministically an early, short partial. The row freezes at truncated text + +`partial:true`, and the viewer shows exactly that. + +The previous fix (migration `d4e5f6a7b8c9`) removed duplicate _rows_ but left the **which-payload-wins** +ordering unguarded, so the truncation survived. + +This is not reasoning-specific: every streamed `say` (text, command_output, tool, mcp, api_req) upserts +through the same path, so any of them can freeze at an early partial. That is the "everywhere" the user +means — one shared persistence bug, all block types. + +## Fix — make the upsert monotonic (race-proof, payload-ordered) + +`telemetry_service.upsert_task_message()`: keep the `ON CONFLICT DO UPDATE` but make it **monotonic** so a +row can only advance toward its most-complete form — an early/short partial committing late can never +clobber a fuller payload or the finalize: + +```python +is_final = not message.get("partial") +base = _insert(TaskMessage).values(task_id=task_id, message_data=payload, message_ts=ts) +on_conflict = dict( + index_elements=["task_id", "message_ts"], + set_={"message_data": base.excluded.message_data}, +) +if not is_final: + on_conflict["where"] = func.length(base.excluded.message_data) >= func.length(TaskMessage.message_data) +stmt = base.on_conflict_do_update(**on_conflict) +``` + +Two cases, because length alone is _not_ a clean monotonic key across the partial→final boundary: + +- A **final** message (`partial` falsy) is **authoritative and always wins** — there is exactly one per + `ts` and it holds the full accumulated text. It bypasses the length check on purpose: dropping the + `"partial":true"` flag can make the final JSON a few bytes _shorter_ than the last partial despite longer + text (this is exactly what broke the first length-only attempt: the `…upsert_is_idempotent_by_ts` test's + final payload is ~6 bytes shorter than its preceding partial). +- A **partial** may only overwrite when its `message_data` is `>=` the stored one. Streamed `partial:true` + chunks carry the _accumulated_ text (`_reasoningMessage += chunk`) with a constant `"partial":true` + flag, so their JSON length grows monotonically — a late, short partial is rejected. + +`is_final` is known in Python (no JSON-in-SQL needed), so the guard stays dialect-agnostic; both +`postgresql.insert` and `sqlite.insert` support `on_conflict_do_update(..., where=…)` with `excluded`. The +`ts is None` append path is unchanged. + +## Out of scope / notes + +- **Already-corrupted historical rows** (`The user says`, etc.) cannot be repaired from the DB — the full + text was never stored; only the extension's own `clineMessages` still hold it. Re-sharing such a task + re-runs `backfill_messages()` (full replace from the extension's complete messages), which heals it. +- No front-end change: the renderers (web `render.js`, VS Code `ReasoningBlock.tsx`) were reviewed and do + not clip expanded content. + +## Verification + +- `uv run pytest tests/test_bridge.py` green; existing idempotent + reasoning-collapse tests still pass + (sequential calls always end at the longest/finalize). +- **New test** `test_task_event_upsert_never_regresses_to_shorter_partial`: deliver the long/final payload + first, then a short early partial for the same ts → row keeps the full text (short partial rejected). + Fails on the unguarded upsert, passes with the guard. +- Manual: drive a live reasoning task, reload the finished task → the reasoning block expands to the full + trace, not just its opening words. diff --git a/ai_plans/2026-06-21_fix-share-response-null-zod-reject.md b/ai_plans/2026-06-21_fix-share-response-null-zod-reject.md new file mode 100644 index 0000000000..890d205a3d --- /dev/null +++ b/ai_plans/2026-06-21_fix-share-response-null-zod-reject.md @@ -0,0 +1,70 @@ +# Fix: "Failed to share task" on HTTP 200 (share response serializes `error: null`) + +**Date:** 2026-06-21 +**Branch:** `feature/self-hosted-remote-task-control` + +## Symptom (user's words) + +> "I got 'Failed to share task' despite the backend returns 200 and I can see the +> task on backend webview." + +## Root cause (proven with evidence) + +A Zod-rejects-`null` mismatch — the **same class of bug** already fixed for the +settings endpoint (see `project_self_hosted_settings_exclude_none` memory / +`ai_plans/2026-06-20_fix-share-button-disabled-null-settings.md`). + +Chain: + +1. `CloudAPI.shareTask` parses the 200 body with `shareResponseSchema.parse(data)` + (`packages/cloud/src/CloudAPI.ts:117`). A `ZodError` makes the call throw. +2. `webviewMessageHandler.ts:873-875` catches any throw and shows + `common:errors.share_task_failed` = **"Failed to share task."** +3. `shareResponseSchema` (`packages/types/src/cloud.ts:223-229`) marks every + optional field with `.optional()`. Zod `.optional()` accepts `undefined` but + **rejects `null`**. +4. Backend `ShareResponse` (`self-hosted-cloudapi/src/schemas/share.py`) has + `error: Optional[str] = None` and **no `exclude_none`**. On the success path it + serializes `"error": null` (plus camelCase via `serialize_by_alias`). The route + `@router.post("/share")` (`routers/extension.py:22`) lacked + `response_model_exclude_none=True`, unlike `/api/extension-settings` + (`routers/settings.py:29`, which carries a comment documenting exactly this). +5. `"error": null` → Zod parse throws → catch → "Failed to share task". + +Why the task is still visible on the webview: the `404 → /api/events/backfill → +re-share` pipeline already persisted the `Task`/`TaskMessage`/`TaskShare` rows. Only +the **final share response fails to parse on the client** — the user sees an error +for an operation that actually succeeded server-side. + +### Ruled out + +- Not an HTTP status problem (it's a 200) and not the auth/`exclude_none` settings + bug from 06-20 (that disabled the button; here the button works and the request + reaches the server). Distinct endpoint, same null-vs-`.optional()` contract. + +## Fix + +`self-hosted-cloudapi/src/routers/extension.py`: add +`response_model_exclude_none=True` to the `/share` route decorator, mirroring +`routers/settings.py`. One-line change + explanatory comment. No client change — +keeps the existing Zod contract intact. + +## Verification + +- Regression test in `tests/test_web_and_share.py`: share an existing task → + assert 200 and that the JSON body contains **no `null` values** and omits the + `error` key entirely on success, so the client Zod schema parses it. +- Full `uv run pytest`. + +## Risk / rollback + +Additive serialization flag, scoped to one route; revert the decorator to roll back. + +## Status — 2026-06-21: FIXED & VERIFIED + +- `routers/extension.py`: `/share` route now `response_model_exclude_none=True` + comment. +- `tests/test_web_and_share.py`: added `test_share_existing_task_response_has_no_null_fields`. +- Proof the test catches the regression: with the flag removed it fails with + `found: ['.error']` (the `error: null` that breaks the client Zod parse); with the + flag it passes. Full suite **52 passed** (was 51). +- **Restart the backend** to pick up the change (FastAPI route is read at import). diff --git a/ai_plans/2026-06-21_fix-stop-button-enabled-when-task-idle.md b/ai_plans/2026-06-21_fix-stop-button-enabled-when-task-idle.md new file mode 100644 index 0000000000..54bf9b4561 --- /dev/null +++ b/ai_plans/2026-06-21_fix-stop-button-enabled-when-task-idle.md @@ -0,0 +1,74 @@ +# Fix: web cockpit Stop button stays enabled when the task is not running + +Date: 2026-06-21 +Branch: feature/self-hosted-remote-task-control + +## Symptom + +On the owner's live task page (`/app/.../task`), the **Stop** button is shown and +enabled even when the task is idle (finished its turn, waiting for the next user +message). Stopping an idle task is a no-op at best and confusing at worst. + +## Root cause (traced, not assumed) + +`isRunning` is the single flag that drives the Stop⇄Resume toggle in the web +cockpit (`live.js` `applyInstanceState`, lines 156-160). It is produced by the +extension's bridge snapshot: + +```ts +// src/extension/bridge.ts (before) +isRunning: !!task && !task.abort, +``` + +`task.abort` is only `true` _after_ an explicit abort. A task that has finished +its turn and is sitting idle (awaiting input) is **not** aborted, so this +expression returns `true` → the cockpit believes the task is running → Stop is +shown. + +The authoritative "is this task actively running" signal already exists: +`Task.taskStatus` (`src/core/task/TaskTokenTracking.ts:177`), derived from the +blocking-ask category (`packages/types/src/message.ts`): + +- `Idle` — `completion_result`, `resume_completed_task`, `api_req_failed`, + `mistake_limit_reached`, `auto_approval_max_req_reached` → **not running** +- `Resumable` — `resume_task` → **not running** (Resume is the action) +- `Interactive` — `tool`, `followup`, `command`, `use_mcp_server` → in-flight, + awaiting approval → **running** (Stop aborts it) +- `Running` — actively streaming → **running** + +The webview's own cancel logic confirms `isStreaming` is the live-work signal +(`ClineProvider.cancelTask` waits for `isStreaming === false`); `taskStatus` +wraps that plus the ask states, so it is the right granularity for the cockpit. + +### Secondary gap (live propagation) + +`BridgeOrchestrator.subscribeToBus` pushes a fresh snapshot on +`TaskModeSwitched`, `TaskTokenUsageUpdated`, `TaskAskResponded`, +`TaskInteractive` — but **not** on `TaskIdle`, `TaskResumable`, `TaskCompleted`, +`TaskAborted`. So even once `isRunning` is correct, when a _running_ task +finishes, no new `instanceState` is emitted and the cockpit never learns the +flag flipped. The API forwards all four events (`src/extension/api.ts:312-347`), +and `snapshot()` always returns a payload, so subscribing is safe. + +### Tertiary gap (initial UI default) + +`task_detail.html` ships Stop with no `display:none` (Resume has it), so the +_default_ rendered state is "running" before any `instanceState` arrives. + +## Fix + +1. **`src/extension/bridge.ts`** — derive `isRunning` from `taskStatus`: + `isRunning = status === Running || status === Interactive`. +2. **`packages/cloud/src/bridge/BridgeOrchestrator.ts`** — also push instance + state on `TaskIdle`, `TaskResumable`, `TaskCompleted`, `TaskAborted`. +3. **`self-hosted-cloudapi/.../live.js` + `task_detail.html`** — default Stop to + hidden / Resume shown until an `instanceState` proves the task is running, and + hide Stop (not just disable) whenever offline. Centralize via `setRunning()`. + +## Verification + +- Idle task → Stop hidden, Resume shown. +- Running task → Stop shown; on completion the orchestrator pushes Idle state and + Stop flips to Resume live (no reload). +- Interactive approval → Stop shown alongside inline Approve/Deny. +- Existing `BridgeOrchestrator.test.ts` snapshot mock still type-checks. diff --git a/ai_plans/2026-06-21_fix-stuck-partial-spinners-duplicate-task-messages.md b/ai_plans/2026-06-21_fix-stuck-partial-spinners-duplicate-task-messages.md new file mode 100644 index 0000000000..9230e52532 --- /dev/null +++ b/ai_plans/2026-06-21_fix-stuck-partial-spinners-duplicate-task-messages.md @@ -0,0 +1,94 @@ +# Fix stuck "active" spinners + stale "Thinking…" on the web task view + +**Date:** 2026-06-21 +**Branch:** `feature/self-hosted-remote-task-control` +**Symptoms (user's words):** some conversation blocks are highlighted as if currently active +(spinning) when nothing is running; the live-header summary says "Thinking…" and never shows +tokens / context length. + +--- + +## Root cause (proven with evidence, not assumed) + +Queried the live Postgres `stork_code.task_messages` directly: + +- **7 duplicate `(task_id, message_ts)` groups**, up to **4 rows each**, all `partial: true`. +- ts `1782038135062` → 3 rows, text lengths **2 / 6 / 9**: successive partial _reasoning_ chunks + of one logical message that should have collapsed into a single upserted row. +- `reasoning, partial=true` = 19 of the last 60 rows; also `tool` and `command_output` affected. + +Why the duplicates exist and never finalize: + +1. `task_messages` has **no unique constraint** on `(task_id, message_ts)` — only plain indexes + (migration `c3d4e5f6a7b8` added the column + index but no uniqueness). +2. `upsert_task_message()` (telemetry_service.py) does a **non-atomic SELECT → INSERT/UPDATE**, each + bridge event in its own `async_session_factory()` session. Reasoning streams emit many + `say("reasoning", …, partial=true)` Message events back-to-back; the relay handler + (`sio.on(TASK_EVENT)`) awaits between SELECT and INSERT, so concurrent partials all miss the + not-yet-committed row and **each INSERTs a new row**. +3. Once ≥2 rows share a ts, the finalizing `partial:false` update calls `scalar_one_or_none()`, + which raises `MultipleResultsFound`. sio.py catches it under `except Exception` ("persistence + must never break the live relay") and **silently drops the finalize** → the rows stay + `partial:true` permanently. + +That one bug drives **both** reported symptoms: + +- **Spinners:** `render.js` sets `active = !!m.partial || !!info.active`, so every stuck + `partial:true` row gets `.running` (spinner + highlight) forever. +- **"Thinking…" + no tokens:** `getActivity()` returns the newest active row's label + (`reasoning → "Thinking…"`); `live.js refreshActivity()` shows it. Token/context come only from a + live `instanceState` (none when the extension is offline), so they stay "—" while the stale row + keeps the activity pinned to "Thinking…". + +## Changes + +### Backend — fix the root cause (atomic, race-proof collapse) + +**New Alembic migration** `d4e5f6a7b8c9_task_messages_unique_ts` (revises `c3d4e5f6a7b8`): + +- Dedup existing rows (Postgres only — SQLite test DBs start clean): keep the longest + `message_data` per `(task_id, message_ts)` (the finalized/most-complete copy), tie-break by `id`, + delete the rest. `WHERE message_ts IS NOT NULL` so null-ts backfill rows are untouched. +- Create **unique index** `uq_task_messages_task_ts` on `(task_id, message_ts)`. NULL ts stays + distinct in both Postgres and SQLite, so legacy/backfilled rows still append. +- `downgrade()` drops the unique index. + +**`telemetry_service.upsert_task_message()`** — replace SELECT-then-write with a dialect-native +upsert keyed on the new unique index: + +- `INSERT … ON CONFLICT (task_id, message_ts) DO UPDATE SET message_data = EXCLUDED.message_data` + via `postgresql.insert` / `sqlite.insert` (both support the same construct; branch on + `db.bind.dialect.name`). +- ts `None` → no conflict target matches → plain append (unchanged backfill behaviour). +- Keep the get-or-create-Task + cross-user guard exactly as today. +- This removes the `scalar_one_or_none()` `MultipleResultsFound` path entirely. + +### Frontend — defense in depth (history replay must not animate) + +A loaded conversation snapshot is point-in-time history; only _subsequently streamed_ live events +should animate. This both fixes the visible symptom for any already-corrupted rows and is correct +on its own. + +**`render.js`** + +- `upsert(m, opts)` gains an `opts.history` flag: when set, `active` is forced `false` (the row is + not registered in `activeByTs`). Live socket events keep calling `upsert(m)` with no flag, so + genuine streaming still spins and the in-place finalize still clears it. +- `renderAll(messages)` passes `{ history: true }`. +- Net effect: on load `getActivity()` returns `null` → the `/shared` static page never spins, and + the live header falls back to `isRunning` ("Working…") instead of a phantom "Thinking…". + +## Out of scope + +- Header tokens/context when the extension is offline: there is no live `instanceState` to source + them, so "—" is correct. Once the activity indicator stops lying, this is no longer confusing. +- Reworking the bridge relay's per-event session model. + +## Verification + +- `uv run pytest` green (existing bridge/web/share tests unchanged). +- New test: `upsert_task_message` called repeatedly for one ts (created → partials → final + `partial:false`) yields exactly **one** row whose stored `partial` is `false`. +- Re-run the DB dup query after migration → 0 duplicate `(task_id, message_ts)` groups. +- Manual: reload a finished task → no spinners, header shows no "Thinking…" when idle; drive a live + task → the streaming tail spins and clears on finalize. diff --git a/ai_plans/2026-06-21_web-task-view-polish.md b/ai_plans/2026-06-21_web-task-view-polish.md new file mode 100644 index 0000000000..e924696415 --- /dev/null +++ b/ai_plans/2026-06-21_web-task-view-polish.md @@ -0,0 +1,98 @@ +# Web task-view polish — live conversation UX + +**Date:** 2026-06-21 +**Branch:** `feature/self-hosted-remote-task-control` +**Goal (user's words):** five fixes to the `/app/tasks/{id}` web view now that the backend streams +tasks live: + +1. after confirm/deny the question still displays — can't tell if it was approved or denied; +2. tool results are always expanded — fold them by default; +3. the result is shown twice (real result + a redundant "Task completed"); +4. very redundant rows (e.g. "API request" in the header _and_ tokens in/out in the body) — make + it a one-liner; +5. show what is executing **now** (api request / tool call / thinking), like the VS Code webview. + +--- + +## Root causes (verified) + +- **#1** `live.js applyAsk()` only hides the ask-bar when a _later_ `instanceState` arrives with + `currentAsk` cleared (pushed on `TaskAskResponded`, see `src/extension/bridge.ts:73`). The + in-conversation ask row is never annotated, and there's no optimistic feedback on click. +- **#2** `render.js toolMsg()` / `command_output` / mcp bodies inject content inline. Only + `reasoning` and the api `request` detail use `
`. +- **#3** `say:completion_result` carries the result text; the trailing empty + `ask:completion_result` hits the same `case` and falls back to `"Task completed."` + (`render.js:68`), producing a second row. +- **#4** `apiReq()` repeats "API request" in both the `.msg-head` label and the `.msg-body`. +- **#5** Nothing reads `m.partial` (streaming) or `instanceState.isRunning` to surface current + activity. + +## Changes + +### `render.js` (applies to both `/shared` read-only and live owner pages) + +- **#3** `completion_result`: `if (!m.text) return null` — drop the empty trailing ask; the result + `say` already rendered. (User confirmed the "Task completed" row is unwanted.) +- **#2** fold `tool`, `command_output`, and mcp response bodies in a collapsed `
` with a + meaningful `` (tool name / path / "Output"). Keep the short `command` itself visible. +- **#4** `apiReq()`: move tokens/cost into the row label; body holds only the optional folded + request. `rowEl()` skips the `.msg-body` div entirely when body is empty → true one-liner. +- **#5** classify returns an `active` hint; `mountConversation.upsert` ORs it with `m.partial`. + Active rows get a `.running` class + pulsing spinner in the head. Streaming → final replaces the + same `ts` row, so the spinner clears itself. Track active rows by `ts`; expose `getActivity()` + (label of the newest active row) for the live header. +- **#1** add `markResolved(ts, decision)` to the conversation controller — badges the ask row + `✓ Approved` / `✗ Denied`; persisted in a `resolvedByTs` map so a row replacement keeps the badge. + +### `live.js` (owner live page only) + +- **#1** remember `lastAsk` from `applyInstanceState`. On Approve/Deny click: optimistically hide + the ask-bar and `convo.markResolved(lastAsk.ts, …)`. Still reconcile from `instanceState`. +- **#5** after each relayed event and on `instanceState`, refresh a header activity indicator from + `convo.getActivity()` (falling back to `isRunning` → "Working…", else idle). + +### `task_detail.html` / `app.css` + +- Add a header activity element (`#live-activity`) next to `#live-status`. +- CSS: `.running` spinner keyframes, resolved-ask badge, compact `.role-api` row, `
` + summary styling for folded tool/output bodies. + +## Out of scope + +Per-tool granular status strings beyond thinking/api/tool/responding; reworking the static +read-only header; image paste. + +## Verification + +- Backend `uv run pytest` still green (existing tests assert `#live-controls`/`live.js` presence on + owner page and absence on `/shared` — unchanged). +- Manual: drive a task from the web — watch the spinner on the streaming row, approve an ask and see + the badge, confirm the API row is one line, tool output folded, single completion row. + +## Round 2 (2026-06-21) — coherence pass + +User feedback after round 1: + +1. `command` must fold to one line like the others (command text in the summary label). +2. The approval question must live **inline in chronological order** (not a floating bar), be + coherent with other rows, and on Approve/Deny **disappear with the decision shown**. The floating + `#ask-bar` is removed; Approve/Deny buttons attach to the ask's conversation row, driven by + `instanceState.currentAsk`. A local `answered` set stops a stale `instanceState` (which still + carries the old `currentAsk` until `TaskAskResponded` lands) from resurrecting the bar. +3. Assistant `text` folds by default, same as reasoning. +4. Every row shows date+time on the right and the **step duration** (gap to the next step's ts). + +Changes: `render.js` — `text`/`command` `fold:true`; `rowEl` adds a right-aligned `.msg-meta` +(time + duration); `mountConversation` gains `setActiveAsk`/`clearActiveAsk` (inline Approve/Deny on +the ask row) and tracks a `tail` row to backfill the previous step's duration on each append. +`live.js` — drives the inline ask from `currentAsk`, ignores already-`answered` asks. `task_detail.html` +— drop the `#ask-bar` block. `app.css` — `.msg-meta`/`.msg-time`/`.msg-dur`, `.ask-pending`, +`.ask-actions-inline`. + +## Status — 2026-06-21: COMPLETE (pending manual e2e) + +All five landed in `render.js` / `live.js` / `task_detail.html` / `app.css`. `node --check` clean +on both JS files; `uv run pytest tests/test_web_and_share.py` → 16 passed. Manual web e2e pending. + + diff --git a/packages/cloud/package.json b/packages/cloud/package.json index 7b9a03d6f8..2505140e09 100644 --- a/packages/cloud/package.json +++ b/packages/cloud/package.json @@ -15,6 +15,7 @@ "ioredis": "^5.6.1", "jwt-decode": "^4.0.0", "p-wait-for": "^5.0.2", + "socket.io-client": "^4.8.1", "zod": "^3.25.76" }, "devDependencies": { diff --git a/packages/cloud/src/CloudAPI.ts b/packages/cloud/src/CloudAPI.ts index 239dc9b564..dc4fcdb6c7 100644 --- a/packages/cloud/src/CloudAPI.ts +++ b/packages/cloud/src/CloudAPI.ts @@ -129,6 +129,7 @@ export class CloudAPI { .object({ userId: z.string(), socketBridgeUrl: z.string(), + socketBridgePath: z.string().optional(), token: z.string(), }) .parse(data), diff --git a/packages/cloud/src/bridge/BridgeOrchestrator.ts b/packages/cloud/src/bridge/BridgeOrchestrator.ts new file mode 100644 index 0000000000..603cb54a25 --- /dev/null +++ b/packages/cloud/src/bridge/BridgeOrchestrator.ts @@ -0,0 +1,213 @@ +import { io, type Socket } from "socket.io-client" + +import { + RooCodeEventName, + TaskBridgeEventName, + TaskSocketEvents, + ExtensionSocketEvents, + HEARTBEAT_INTERVAL_MS, + taskBridgeCommandSchema, + type TaskBridgeCommand, +} from "@roo-code/types" + +import { dispatchBridgeCommand } from "./commandHandlers.js" +import type { BridgeConfig, BridgeProvider, InstanceStatePayload } from "./types.js" + +type Logger = (...args: unknown[]) => void + +type BusListener = (...args: unknown[]) => void + +/** The slice of the extension `API` event bus the orchestrator subscribes to. */ +export interface BridgeEventSource { + on(event: string, listener: BusListener): void + off(event: string, listener: BusListener): void +} + +export interface BridgeOrchestratorOptions { + /** Fetch a fresh bridge config (short-lived token) — re-called on every (re)connect. */ + getBridgeConfig: () => Promise + provider: BridgeProvider + events: BridgeEventSource + workspacePath: string + /** Build the live header/control snapshot for the active task. */ + snapshot: (taskId: string) => Promise + log?: Logger + /** Injectable for tests; defaults to the real socket.io-client. */ + ioFactory?: typeof io +} + +/** + * Connects the extension to the cloud socket.io bridge and wires it both ways: + * + * - **up** (extension → server): registers an instance, heartbeats, and forwards + * live task events (`message`, `instanceState`) so the web cockpit renders live. + * - **down** (server → extension): receives relayed browser commands and dispatches + * them to the verified control entry points via {@link dispatchBridgeCommand}. + * + * The orchestrator only ever connects when started (the opt-in setting gate lives + * in the extension host); `stop()` fully tears down the socket, heartbeat, and bus + * subscriptions so toggling the setting off severs remote control immediately. + */ +export class BridgeOrchestrator { + private socket: Socket | null = null + private heartbeat: ReturnType | null = null + private userId: string | null = null + private started = false + private readonly listeners: Array<[string, BusListener]> = [] + + constructor(private readonly options: BridgeOrchestratorOptions) {} + + private log(...args: unknown[]) { + this.options.log?.("[BridgeOrchestrator]", ...args) + } + + get isConnected(): boolean { + return this.socket?.connected ?? false + } + + async start(): Promise { + if (this.started) return + this.started = true + + const config = await this.options.getBridgeConfig() + this.userId = config.userId + + const factory = this.options.ioFactory ?? io + const socket = factory(config.socketBridgeUrl, { + path: config.socketBridgePath || "/bridge/socket.io", + transports: ["websocket", "polling"], + // Re-mint the short-lived token on every (re)connect attempt. + auth: async (cb: (data: Record) => void) => { + try { + const fresh = await this.options.getBridgeConfig() + cb({ token: fresh.token }) + } catch { + cb({ token: config.token }) + } + }, + }) + this.socket = socket + + socket.on("connect", () => { + this.log("connected", socket.id) + this.register() + this.startHeartbeat() + }) + socket.on("disconnect", (reason: string) => this.log("disconnected", reason)) + socket.on(TaskSocketEvents.RELAYED_COMMAND, (data: unknown) => void this.onRelayedCommand(data)) + socket.on(ExtensionSocketEvents.RELAYED_COMMAND, (data: unknown) => void this.onRelayedCommand(data)) + + this.subscribeToBus() + } + + async stop(): Promise { + if (!this.started) return + this.started = false + this.stopHeartbeat() + this.unsubscribeFromBus() + if (this.socket) { + try { + this.socket.emit(ExtensionSocketEvents.UNREGISTER, {}) + } catch { + // best-effort + } + this.socket.removeAllListeners() + this.socket.disconnect() + this.socket = null + } + this.userId = null + } + + // --- extension → server ------------------------------------------------- + + private register() { + if (!this.socket || !this.userId) return + this.socket.emit(ExtensionSocketEvents.REGISTER, { + userId: this.userId, + workspacePath: this.options.workspacePath, + lastHeartbeat: Date.now(), + }) + } + + private startHeartbeat() { + this.stopHeartbeat() + this.heartbeat = setInterval(() => { + this.socket?.emit(ExtensionSocketEvents.HEARTBEAT, {}) + }, HEARTBEAT_INTERVAL_MS) + } + + private stopHeartbeat() { + if (this.heartbeat) { + clearInterval(this.heartbeat) + this.heartbeat = null + } + } + + private subscribeToBus() { + const onMessage: BusListener = (...args) => { + const payload = args[0] as { taskId: string; action?: string; message: unknown } + this.socket?.emit(TaskSocketEvents.EVENT, { + type: TaskBridgeEventName.Message, + taskId: payload.taskId, + action: payload.action ?? "", + message: payload.message, + }) + } + const onState: BusListener = (...args) => void this.pushInstanceState(args[0] as string) + + this.add(RooCodeEventName.Message, onMessage) + this.add(RooCodeEventName.TaskModeSwitched, onState) + this.add(RooCodeEventName.TaskTokenUsageUpdated, onState) + this.add(RooCodeEventName.TaskAskResponded, onState) + this.add(RooCodeEventName.TaskInteractive, onState) + // Terminal/idle transitions flip isRunning false; without these the cockpit + // would keep showing Stop after a running task finishes. + this.add(RooCodeEventName.TaskIdle, onState) + this.add(RooCodeEventName.TaskResumable, onState) + this.add(RooCodeEventName.TaskCompleted, onState) + this.add(RooCodeEventName.TaskAborted, onState) + } + + private add(event: string, listener: BusListener) { + this.options.events.on(event, listener) + this.listeners.push([event, listener]) + } + + private unsubscribeFromBus() { + for (const [event, listener] of this.listeners) { + this.options.events.off(event, listener) + } + this.listeners.length = 0 + } + + private async pushInstanceState(taskId: string) { + if (!this.socket || !taskId) return + try { + const state = await this.options.snapshot(taskId) + if (!state) return + this.socket.emit(TaskSocketEvents.EVENT, { + type: TaskBridgeEventName.InstanceState, + taskId, + ...state, + }) + } catch (error) { + this.log("snapshot failed", error) + } + } + + // --- server → extension ------------------------------------------------- + + private async onRelayedCommand(data: unknown) { + const parsed = taskBridgeCommandSchema.safeParse(data) + if (!parsed.success) { + this.log("dropped malformed command", parsed.error?.message) + return + } + const command: TaskBridgeCommand = parsed.data + try { + await dispatchBridgeCommand(command, this.options.provider) + } catch (error) { + this.log("command dispatch failed", command.type, error) + } + } +} diff --git a/packages/cloud/src/bridge/__tests__/BridgeOrchestrator.test.ts b/packages/cloud/src/bridge/__tests__/BridgeOrchestrator.test.ts new file mode 100644 index 0000000000..886682335e --- /dev/null +++ b/packages/cloud/src/bridge/__tests__/BridgeOrchestrator.test.ts @@ -0,0 +1,160 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest" + +import { + RooCodeEventName, + TaskBridgeEventName, + TaskSocketEvents, + ExtensionSocketEvents, + TaskBridgeCommandName, +} from "@roo-code/types" + +import { BridgeOrchestrator, type BridgeEventSource } from "../BridgeOrchestrator.js" +import type { BridgeProvider } from "../types.js" + +/** Minimal EventEmitter standing in for both the socket and the API bus. */ +class FakeEmitter { + handlers = new Map void>>() + emitted: Array<{ event: string; data: any }> = [] + connected = true + id = "fake-sid" + + on(event: string, cb: (...a: any[]) => void) { + const list = this.handlers.get(event) ?? [] + list.push(cb) + this.handlers.set(event, list) + } + off(event: string, cb: (...a: any[]) => void) { + const list = this.handlers.get(event) ?? [] + this.handlers.set( + event, + list.filter((h) => h !== cb), + ) + } + emit(event: string, data?: any) { + this.emitted.push({ event, data }) + } + removeAllListeners() { + this.handlers.clear() + } + disconnect() { + this.connected = false + } + /** Test helper: fire a server-pushed event into the orchestrator's listeners. */ + fire(event: string, ...args: any[]) { + for (const h of this.handlers.get(event) ?? []) h(...args) + } +} + +function makeProvider() { + const provider: BridgeProvider = { + getCurrentTask: vi.fn(() => undefined), + cancelTask: vi.fn(async () => {}), + showTaskWithId: vi.fn(async () => undefined), + postStateToWebview: vi.fn(async () => {}), + contextProxy: { setValue: vi.fn(async () => {}) }, + } + return provider +} + +const CONFIG = { + userId: "user-1", + socketBridgeUrl: "http://localhost:8085", + socketBridgePath: "/bridge/socket.io", + token: "tok-1", +} + +describe("BridgeOrchestrator", () => { + let socket: FakeEmitter + let bus: FakeEmitter + let provider: BridgeProvider + let ioFactory: ReturnType + + beforeEach(() => { + vi.useFakeTimers() + socket = new FakeEmitter() + bus = new FakeEmitter() + provider = makeProvider() + ioFactory = vi.fn(() => socket) + }) + + afterEach(() => { + vi.useRealTimers() + }) + + function build() { + return new BridgeOrchestrator({ + getBridgeConfig: vi.fn(async () => CONFIG), + provider, + events: bus as unknown as BridgeEventSource, + workspacePath: "/work", + snapshot: vi.fn(async () => ({ mode: "code", isRunning: true })), + ioFactory: ioFactory as any, + }) + } + + it("does not connect until start() is called", async () => { + build() + expect(ioFactory).not.toHaveBeenCalled() + }) + + it("connects and registers the instance on socket connect", async () => { + const orch = build() + await orch.start() + expect(ioFactory).toHaveBeenCalledWith( + CONFIG.socketBridgeUrl, + expect.objectContaining({ path: CONFIG.socketBridgePath }), + ) + + socket.fire("connect") + const register = socket.emitted.find((e) => e.event === ExtensionSocketEvents.REGISTER) + expect(register).toBeTruthy() + expect(register!.data).toMatchObject({ userId: "user-1", workspacePath: "/work" }) + }) + + it("forwards API Message bus events to the task:event channel", async () => { + const orch = build() + await orch.start() + bus.fire(RooCodeEventName.Message, { taskId: "task-9", action: "created", message: { ts: 1, type: "say" } }) + + const evt = socket.emitted.find((e) => e.event === TaskSocketEvents.EVENT) + expect(evt).toBeTruthy() + expect(evt!.data).toMatchObject({ + type: TaskBridgeEventName.Message, + taskId: "task-9", + action: "created", + }) + }) + + it("dispatches a relayed stop_task command to the provider", async () => { + const orch = build() + await orch.start() + socket.fire(TaskSocketEvents.RELAYED_COMMAND, { + type: TaskBridgeCommandName.StopTask, + taskId: "task-9", + timestamp: 1, + }) + await vi.runAllTimersAsync() + expect(provider.cancelTask).toHaveBeenCalledTimes(1) + }) + + it("ignores malformed relayed commands without throwing", async () => { + const orch = build() + await orch.start() + socket.fire(TaskSocketEvents.RELAYED_COMMAND, { type: "not_a_command", foo: 1 }) + await vi.runAllTimersAsync() + expect(provider.cancelTask).not.toHaveBeenCalled() + }) + + it("stop() tears down and stops forwarding bus events", async () => { + const orch = build() + await orch.start() + await orch.stop() + expect(socket.connected).toBe(false) + + // A bus event after stop must not be forwarded (listeners removed). + const before = socket.emitted.length + bus.fire(RooCodeEventName.Message, { taskId: "t", action: "x", message: {} }) + expect(socket.emitted.length).toBe(before) + }) +}) diff --git a/packages/cloud/src/bridge/__tests__/commandHandlers.test.ts b/packages/cloud/src/bridge/__tests__/commandHandlers.test.ts new file mode 100644 index 0000000000..3cfd60bdea --- /dev/null +++ b/packages/cloud/src/bridge/__tests__/commandHandlers.test.ts @@ -0,0 +1,125 @@ +import { describe, it, expect, vi, beforeEach } from "vitest" + +import { TaskBridgeCommandName, type TaskBridgeCommand } from "@roo-code/types" + +import { dispatchBridgeCommand } from "../commandHandlers.js" +import type { BridgeProvider, BridgeTask } from "../types.js" + +function makeTask(): BridgeTask & { + submitUserMessage: ReturnType + handleWebviewAskResponse: ReturnType +} { + return { + taskId: "task-1", + submitUserMessage: vi.fn(async () => {}), + handleWebviewAskResponse: vi.fn(), + } +} + +function makeProvider(task: BridgeTask | undefined) { + const setValue = vi.fn(async () => {}) + const provider: BridgeProvider = { + getCurrentTask: () => task, + cancelTask: vi.fn(async () => {}), + showTaskWithId: vi.fn(async () => undefined), + postStateToWebview: vi.fn(async () => {}), + contextProxy: { setValue }, + } + return { provider, setValue } +} + +const ts = 123 + +describe("dispatchBridgeCommand", () => { + let task: ReturnType + let provider: BridgeProvider + let setValue: ReturnType + + beforeEach(() => { + task = makeTask() + ;({ provider, setValue } = makeProvider(task)) + }) + + it("message → submitUserMessage with text/images/mode/profile", async () => { + const cmd: TaskBridgeCommand = { + type: TaskBridgeCommandName.Message, + taskId: "task-1", + payload: { text: "hello", images: ["data:img"], mode: "code", providerProfile: "default" }, + timestamp: ts, + } + await dispatchBridgeCommand(cmd, provider) + expect(task.submitUserMessage).toHaveBeenCalledWith("hello", ["data:img"], "code", "default") + }) + + it("approve_ask → handleWebviewAskResponse('yesButtonClicked', …)", async () => { + await dispatchBridgeCommand( + { type: TaskBridgeCommandName.ApproveAsk, taskId: "task-1", payload: { text: "ok" }, timestamp: ts }, + provider, + ) + expect(task.handleWebviewAskResponse).toHaveBeenCalledWith("yesButtonClicked", "ok", undefined) + }) + + it("deny_ask → handleWebviewAskResponse('noButtonClicked', …)", async () => { + await dispatchBridgeCommand( + { type: TaskBridgeCommandName.DenyAsk, taskId: "task-1", payload: {}, timestamp: ts }, + provider, + ) + expect(task.handleWebviewAskResponse).toHaveBeenCalledWith("noButtonClicked", undefined, undefined) + }) + + it("stop_task → provider.cancelTask()", async () => { + await dispatchBridgeCommand({ type: TaskBridgeCommandName.StopTask, taskId: "task-1", timestamp: ts }, provider) + expect(provider.cancelTask).toHaveBeenCalledTimes(1) + }) + + it("set_auto_approval → setValue per provided key, then a single postStateToWebview", async () => { + await dispatchBridgeCommand( + { + type: TaskBridgeCommandName.SetAutoApproval, + taskId: "task-1", + payload: { autoApprovalEnabled: true, autoApprovalMode: "autonomous", alwaysAllowExecute: false }, + timestamp: ts, + }, + provider, + ) + expect(setValue).toHaveBeenCalledWith("autoApprovalEnabled", true) + expect(setValue).toHaveBeenCalledWith("autoApprovalMode", "autonomous") + expect(setValue).toHaveBeenCalledWith("alwaysAllowExecute", false) + // Keys not in the payload must not be touched. + expect(setValue).toHaveBeenCalledTimes(3) + expect(provider.postStateToWebview).toHaveBeenCalledTimes(1) + }) + + it("set_auto_approval with empty payload does not push state", async () => { + await dispatchBridgeCommand( + { type: TaskBridgeCommandName.SetAutoApproval, taskId: "task-1", payload: {}, timestamp: ts }, + provider, + ) + expect(setValue).not.toHaveBeenCalled() + expect(provider.postStateToWebview).not.toHaveBeenCalled() + }) + + it("resume_task → provider.showTaskWithId(taskId)", async () => { + await dispatchBridgeCommand( + { type: TaskBridgeCommandName.ResumeTask, taskId: "task-hist", timestamp: ts }, + provider, + ) + expect(provider.showTaskWithId).toHaveBeenCalledWith("task-hist") + }) + + it("live-task commands no-op (no throw) when there is no current task", async () => { + const { provider: empty } = makeProvider(undefined) + await expect( + dispatchBridgeCommand( + { type: TaskBridgeCommandName.Message, taskId: "x", payload: { text: "hi" }, timestamp: ts }, + empty, + ), + ).resolves.toBeUndefined() + await expect( + dispatchBridgeCommand( + { type: TaskBridgeCommandName.ApproveAsk, taskId: "x", payload: {}, timestamp: ts }, + empty, + ), + ).resolves.toBeUndefined() + }) +}) diff --git a/packages/cloud/src/bridge/commandHandlers.ts b/packages/cloud/src/bridge/commandHandlers.ts new file mode 100644 index 0000000000..e238f6c9a8 --- /dev/null +++ b/packages/cloud/src/bridge/commandHandlers.ts @@ -0,0 +1,81 @@ +import { TaskBridgeCommandName, type TaskBridgeCommand, type AutoApprovalSettings } from "@roo-code/types" + +import type { BridgeProvider } from "./types.js" + +/** + * The auto-approval keys the web cockpit can steer. Each maps 1:1 to a + * GlobalSettings key the extension stores via `contextProxy.setValue`. + */ +const AUTO_APPROVAL_KEYS: (keyof AutoApprovalSettings)[] = [ + "autoApprovalEnabled", + "autoApprovalMode", + "alwaysAllowReadOnly", + "alwaysAllowWrite", + "alwaysAllowExecute", + "alwaysAllowMcp", + "alwaysAllowModeSwitch", + "alwaysAllowSubtasks", +] + +async function applyAutoApproval(payload: AutoApprovalSettings, provider: BridgeProvider): Promise { + let changed = false + for (const key of AUTO_APPROVAL_KEYS) { + const value = payload[key] + if (value !== undefined) { + await provider.contextProxy.setValue(key, value) + changed = true + } + } + // Push state once so the VS Code panel reflects the change the web made. + if (changed) { + await provider.postStateToWebview() + } +} + +/** + * Map a single browser-issued `TaskBridgeCommand` to the verified extension + * control entry points. Pure dispatch — the orchestrator validates the command + * shape before calling this, so each branch can trust its payload. + * + * Commands that act on the live task no-op when there is no current task (e.g. + * the user closed it); `resume_task` reopens one by id regardless. + */ +export async function dispatchBridgeCommand(command: TaskBridgeCommand, provider: BridgeProvider): Promise { + switch (command.type) { + case TaskBridgeCommandName.Message: { + const task = provider.getCurrentTask() + if (!task) return + await task.submitUserMessage( + command.payload.text, + command.payload.images, + command.payload.mode, + command.payload.providerProfile, + ) + return + } + case TaskBridgeCommandName.ApproveAsk: { + const task = provider.getCurrentTask() + if (!task) return + task.handleWebviewAskResponse("yesButtonClicked", command.payload.text, command.payload.images) + return + } + case TaskBridgeCommandName.DenyAsk: { + const task = provider.getCurrentTask() + if (!task) return + task.handleWebviewAskResponse("noButtonClicked", command.payload.text, command.payload.images) + return + } + case TaskBridgeCommandName.StopTask: { + await provider.cancelTask() + return + } + case TaskBridgeCommandName.SetAutoApproval: { + await applyAutoApproval(command.payload, provider) + return + } + case TaskBridgeCommandName.ResumeTask: { + await provider.showTaskWithId(command.taskId) + return + } + } +} diff --git a/packages/cloud/src/bridge/types.ts b/packages/cloud/src/bridge/types.ts new file mode 100644 index 0000000000..f080cf09a4 --- /dev/null +++ b/packages/cloud/src/bridge/types.ts @@ -0,0 +1,48 @@ +import type { AutoApprovalSettings, TokenUsage, ClineMessage } from "@roo-code/types" + +/** + * The minimal control surface the bridge needs from a live Task. Declared as a + * structural interface (not the concrete `Task`) so the command dispatcher is + * unit-testable with a plain mock and `@roo-code/cloud` stays free of a runtime + * dependency on the extension host `src/` tree. + */ +export interface BridgeTask { + taskId: string + submitUserMessage(text: string, images?: string[], mode?: string, providerProfile?: string): Promise + handleWebviewAskResponse( + askResponse: "yesButtonClicked" | "noButtonClicked" | "messageResponse", + text?: string, + images?: string[], + ): void +} + +/** + * The minimal control surface the bridge needs from the ClineProvider. + */ +export interface BridgeProvider { + getCurrentTask(): BridgeTask | undefined + cancelTask(): Promise + showTaskWithId(id: string): Promise + postStateToWebview(): Promise + contextProxy: { + setValue(key: string, value: unknown): Promise | void + } +} + +/** The live header/control snapshot pushed to the web cockpit. */ +export interface InstanceStatePayload { + mode?: string + isRunning?: boolean + autoApproval?: AutoApprovalSettings + tokenUsage?: TokenUsage + contextTokens?: number + contextWindow?: number + currentAsk?: ClineMessage +} + +export interface BridgeConfig { + userId: string + socketBridgeUrl: string + socketBridgePath?: string + token: string +} diff --git a/packages/cloud/src/index.ts b/packages/cloud/src/index.ts index 8792176fee..a0a49500fd 100644 --- a/packages/cloud/src/index.ts +++ b/packages/cloud/src/index.ts @@ -4,3 +4,8 @@ export { CloudService } from "./CloudService.js" export { RetryQueue } from "./retry-queue/index.js" export type { QueuedRequest, QueueStats, RetryQueueConfig, RetryQueueEvents } from "./retry-queue/index.js" + +export { BridgeOrchestrator } from "./bridge/BridgeOrchestrator.js" +export type { BridgeOrchestratorOptions, BridgeEventSource } from "./bridge/BridgeOrchestrator.js" +export { dispatchBridgeCommand } from "./bridge/commandHandlers.js" +export type { BridgeProvider, BridgeTask, BridgeConfig, InstanceStatePayload } from "./bridge/types.js" diff --git a/packages/types/src/cloud.ts b/packages/types/src/cloud.ts index c991cdb1e6..0edd3bf4c3 100644 --- a/packages/types/src/cloud.ts +++ b/packages/types/src/cloud.ts @@ -4,7 +4,7 @@ import { z } from "zod" import { RooCodeEventName } from "./events.js" import { TaskStatus, taskMetadataSchema } from "./task.js" -import { globalSettingsSchema } from "./global-settings.js" +import { globalSettingsSchema, autoApprovalModes } from "./global-settings.js" import { providerSettingsWithIdSchema } from "./provider-settings.js" import { mcpMarketplaceItemSchema } from "./marketplace.js" import { clineMessageSchema, queuedMessageSchema, tokenUsageSchema } from "./message.js" @@ -424,6 +424,27 @@ export const extensionInstanceSchema = z.object({ export type ExtensionInstance = z.infer +/** + * AutoApprovalSettings + * + * The subset of auto-approval state the web cockpit can both display and steer: + * the master kill-switch, the main mode selector (default / bypass / autonomous), + * and the detailed per-tool toggles. Mirrors the same keys in GlobalSettings. + */ + +export const autoApprovalSettingsSchema = z.object({ + autoApprovalEnabled: z.boolean().optional(), + autoApprovalMode: z.enum(autoApprovalModes).optional(), + alwaysAllowReadOnly: z.boolean().optional(), + alwaysAllowWrite: z.boolean().optional(), + alwaysAllowExecute: z.boolean().optional(), + alwaysAllowMcp: z.boolean().optional(), + alwaysAllowModeSwitch: z.boolean().optional(), + alwaysAllowSubtasks: z.boolean().optional(), +}) + +export type AutoApprovalSettings = z.infer + /** * TaskBridgeEvent */ @@ -432,6 +453,7 @@ export enum TaskBridgeEventName { Message = RooCodeEventName.Message, TaskModeSwitched = RooCodeEventName.TaskModeSwitched, TaskInteractive = RooCodeEventName.TaskInteractive, + InstanceState = "instanceState", } export const taskBridgeEventSchema = z.discriminatedUnion("type", [ @@ -450,6 +472,19 @@ export const taskBridgeEventSchema = z.discriminatedUnion("type", [ type: z.literal(TaskBridgeEventName.TaskInteractive), taskId: z.string(), }), + // Periodic snapshot so the web cockpit can render the header + controls live: + // current mode, the auto-approval state, token/context usage, and the pending ask (if any). + z.object({ + type: z.literal(TaskBridgeEventName.InstanceState), + taskId: z.string(), + mode: z.string().optional(), + isRunning: z.boolean().optional(), + autoApproval: autoApprovalSettingsSchema.optional(), + tokenUsage: tokenUsageSchema.optional(), + contextTokens: z.number().optional(), + contextWindow: z.number().optional(), + currentAsk: clineMessageSchema.optional(), + }), ]) export type TaskBridgeEvent = z.infer @@ -462,6 +497,9 @@ export enum TaskBridgeCommandName { Message = "message", ApproveAsk = "approve_ask", DenyAsk = "deny_ask", + StopTask = "stop_task", + SetAutoApproval = "set_auto_approval", + ResumeTask = "resume_task", } export const taskBridgeCommandSchema = z.discriminatedUnion("type", [ @@ -494,6 +532,22 @@ export const taskBridgeCommandSchema = z.discriminatedUnion("type", [ }), timestamp: z.number(), }), + z.object({ + type: z.literal(TaskBridgeCommandName.StopTask), + taskId: z.string(), + timestamp: z.number(), + }), + z.object({ + type: z.literal(TaskBridgeCommandName.SetAutoApproval), + taskId: z.string(), + payload: autoApprovalSettingsSchema, + timestamp: z.number(), + }), + z.object({ + type: z.literal(TaskBridgeCommandName.ResumeTask), + taskId: z.string(), + timestamp: z.number(), + }), ]) export type TaskBridgeCommand = z.infer diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 902d54aae1..ffd10ac8a8 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -369,6 +369,9 @@ importers: p-wait-for: specifier: ^5.0.2 version: 5.0.2 + socket.io-client: + specifier: ^4.8.1 + version: 4.8.3 zod: specifier: 3.25.76 version: 3.25.76 @@ -3983,6 +3986,9 @@ packages: resolution: {integrity: sha512-4aUIteuyxtBUhVdiQqcDhKFitwfd9hqoSDYY2KRXiWtgoWJ9Bmise+KfEPDiVHWeJepvF8xJO9/9+WDIciMFFw==} engines: {node: '>=18.0.0'} + '@socket.io/component-emitter@3.1.2': + resolution: {integrity: sha512-9BCxFwvbGg/RsZK9tjXd8s4UcwR0MWeFQ1XEKIQVVvAGJyINdrqKMcTRyLoK8Rse1GjzLV9cwjWV1olXRWEXVA==} + '@standard-schema/spec@1.1.0': resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} @@ -6045,6 +6051,13 @@ packages: end-of-stream@1.4.4: resolution: {integrity: sha512-+uw1inIHVPQoaVuHzRyXd21icM+cnt4CzD5rW+NC1wjOUSTOs+Te7FOv7AhN7vS9x/oIyhLP5PR1H+phQAHu5Q==} + engine.io-client@6.6.6: + resolution: {integrity: sha512-iY6QdftLQ9pyiPoX082bpf/u1UewnOaJrtJIF9T0++QB34lZrj0uP+Q/bj8AlUsAxqhnkTV2BS8SBZSxOmoV5Q==} + + engine.io-parser@5.2.3: + resolution: {integrity: sha512-HqD3yTBfnBxIrbnM1DoD6Pcq8NECnh8d4As1Qgh0z5Gg3jRRIqijury0CL3ghu/edArpUYiYqQiDUQBIs4np3Q==} + engines: {node: '>=10.0.0'} + enhanced-resolve@5.18.1: resolution: {integrity: sha512-ZSW3ma5GkcQBIpwZTSRAI8N71Uuwgs93IezB7mf7R60tC8ZbJideoDNKjHn2O9KIlx6rkGTTEk1xUCK2E1Y2Yg==} engines: {node: '>=10.13.0'} @@ -9420,6 +9433,14 @@ packages: resolution: {integrity: sha512-UOPtVuYkzYGee0Bd2Szz8d2G3RfMfJ2t3qVdZUAozZyAk+a0Sxa+QKix0YCwjL/A1RR0ar44nCxaoN9FxdJGwA==} engines: {node: '>= 18'} + socket.io-client@4.8.3: + resolution: {integrity: sha512-uP0bpjWrjQmUt5DTHq9RuoCBdFJF10cdX9X+a368j/Ft0wmaVgxlrjvK3kjvgCODOMMOz9lcaRzxmso0bTWZ/g==} + engines: {node: '>=10.0.0'} + + socket.io-parser@4.2.6: + resolution: {integrity: sha512-asJqbVBDsBCJx0pTqw3WfesSY0iRX+2xzWEWzrpcH7L6fLzrhyF8WPI8UaeM4YCuDfpwA/cgsdugMsmtz8EJeg==} + engines: {node: '>=10.0.0'} + socks-proxy-agent@8.0.5: resolution: {integrity: sha512-HehCEsotFqbPW9sJ8WVYB6UbmIMv7kUUORIF2Nncq4VQvBfNBLibW9YZR5dlYCSUhwcD628pRllm7n+E+YTzJw==} engines: {node: '>= 14'} @@ -10588,6 +10609,10 @@ packages: xmlchars@2.2.0: resolution: {integrity: sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==} + xmlhttprequest-ssl@2.1.2: + resolution: {integrity: sha512-TEU+nJVUUnA4CYJFLvK5X9AOeH4KvDvhIfm0vV1GaQRtchnG0hgK5p8hw/xjv8cunWYCsiPCSDzObPyhEwq3KQ==} + engines: {node: '>=0.4.0'} + xtend@4.0.2: resolution: {integrity: sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==} engines: {node: '>=0.4'} @@ -12690,7 +12715,7 @@ snapshots: '@puppeteer/browsers@2.10.5': dependencies: - debug: 4.4.1(supports-color@8.1.1) + debug: 4.4.3 extract-zip: 2.0.1 progress: 2.0.3 proxy-agent: 6.5.0 @@ -12703,7 +12728,7 @@ snapshots: '@puppeteer/browsers@2.6.1': dependencies: - debug: 4.4.1(supports-color@8.1.1) + debug: 4.4.3 extract-zip: 2.0.1 progress: 2.0.3 proxy-agent: 6.5.0 @@ -13880,6 +13905,8 @@ snapshots: dependencies: tslib: 2.8.1 + '@socket.io/component-emitter@3.1.2': {} + '@standard-schema/spec@1.1.0': {} '@standard-schema/utils@0.3.0': {} @@ -14475,7 +14502,7 @@ snapshots: '@typescript-eslint/types': 8.32.1 '@typescript-eslint/typescript-estree': 8.32.1(typescript@5.8.3) '@typescript-eslint/visitor-keys': 8.32.1 - debug: 4.4.1(supports-color@8.1.1) + debug: 4.4.3 eslint: 9.27.0(jiti@2.4.2) typescript: 5.8.3 transitivePeerDependencies: @@ -16011,6 +16038,20 @@ snapshots: dependencies: once: 1.4.0 + engine.io-client@6.6.6: + dependencies: + '@socket.io/component-emitter': 3.1.2 + debug: 4.4.3 + engine.io-parser: 5.2.3 + ws: 8.21.0 + xmlhttprequest-ssl: 2.1.2 + transitivePeerDependencies: + - bufferutil + - supports-color + - utf-8-validate + + engine.io-parser@5.2.3: {} + enhanced-resolve@5.18.1: dependencies: graceful-fs: 4.2.11 @@ -16144,7 +16185,7 @@ snapshots: esbuild-register@3.6.0(esbuild@0.25.9): dependencies: - debug: 4.4.1(supports-color@8.1.1) + debug: 4.4.3 esbuild: 0.25.9 transitivePeerDependencies: - supports-color @@ -16496,7 +16537,7 @@ snapshots: content-type: 1.0.5 cookie: 0.7.2 cookie-signature: 1.2.2 - debug: 4.4.1(supports-color@8.1.1) + debug: 4.4.3 encodeurl: 2.0.0 escape-html: 1.0.3 etag: 1.8.1 @@ -19369,7 +19410,7 @@ snapshots: dependencies: '@puppeteer/browsers': 2.10.5 chromium-bidi: 5.1.0(devtools-protocol@0.0.1452169) - debug: 4.4.1(supports-color@8.1.1) + debug: 4.4.3 devtools-protocol: 0.0.1452169 typed-query-selector: 2.12.0 ws: 8.18.2 @@ -20158,6 +20199,24 @@ snapshots: smol-toml@1.3.4: {} + socket.io-client@4.8.3: + dependencies: + '@socket.io/component-emitter': 3.1.2 + debug: 4.4.3 + engine.io-client: 6.6.6 + socket.io-parser: 4.2.6 + transitivePeerDependencies: + - bufferutil + - supports-color + - utf-8-validate + + socket.io-parser@4.2.6: + dependencies: + '@socket.io/component-emitter': 3.1.2 + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + socks-proxy-agent@8.0.5: dependencies: agent-base: 7.1.3 @@ -21567,8 +21626,7 @@ snapshots: ws@8.18.3: {} - ws@8.21.0: - optional: true + ws@8.21.0: {} xml-name-validator@5.0.0: {} @@ -21583,6 +21641,8 @@ snapshots: xmlchars@2.2.0: {} + xmlhttprequest-ssl@2.1.2: {} + xtend@4.0.2: {} y18n@5.0.8: {} diff --git a/self-hosted-cloudapi/alembic/versions/c3d4e5f6a7b8_task_message_ts.py b/self-hosted-cloudapi/alembic/versions/c3d4e5f6a7b8_task_message_ts.py new file mode 100644 index 0000000000..363b82a125 --- /dev/null +++ b/self-hosted-cloudapi/alembic/versions/c3d4e5f6a7b8_task_message_ts.py @@ -0,0 +1,37 @@ +"""Add task_messages.message_ts for live-bridge upserts. + +The live remote-control bridge streams a single ClineMessage through several +states (created → partial updates → final). To avoid appending duplicate rows +for the same logical message, the relay upserts by the message's `ts`. This adds +a nullable, indexed BigInteger column to hold it (null for legacy/backfilled +rows, which continue to be appended as before). + +Revision ID: c3d4e5f6a7b8 +Revises: b2c3d4e5f6a7 +Create Date: 2026-06-20 12:00:00.000000 + +""" + +from alembic import op +import sqlalchemy as sa + +# revision identifiers, used by Alembic. +revision = "c3d4e5f6a7b8" +down_revision = "b2c3d4e5f6a7" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.add_column( + "task_messages", + sa.Column("message_ts", sa.BigInteger(), nullable=True), + ) + op.create_index( + "ix_task_messages_message_ts", "task_messages", ["message_ts"] + ) + + +def downgrade() -> None: + op.drop_index("ix_task_messages_message_ts", table_name="task_messages") + op.drop_column("task_messages", "message_ts") diff --git a/self-hosted-cloudapi/alembic/versions/d4e5f6a7b8c9_task_messages_unique_ts.py b/self-hosted-cloudapi/alembic/versions/d4e5f6a7b8c9_task_messages_unique_ts.py new file mode 100644 index 0000000000..b2350caa55 --- /dev/null +++ b/self-hosted-cloudapi/alembic/versions/d4e5f6a7b8c9_task_messages_unique_ts.py @@ -0,0 +1,62 @@ +"""Deduplicate task_messages and enforce a unique (task_id, message_ts). + +The live bridge relays a single ClineMessage through many states +(created → partial updates → final). The relay upserts by `ts`, but with only a +plain index and a non-atomic SELECT-then-write, rapid partial events (notably +streaming `reasoning`) raced and inserted duplicate `partial:true` rows. Once +duplicates existed, the finalizing `partial:false` update hit +`scalar_one_or_none()` → `MultipleResultsFound`, which the relay swallowed, so +the rows stayed `partial:true` forever (stuck spinners / phantom "Thinking…"). + +This dedups existing rows and adds a real unique index so the upsert can use a +race-proof `ON CONFLICT … DO UPDATE`. + +Revision ID: d4e5f6a7b8c9 +Revises: c3d4e5f6a7b8 +Create Date: 2026-06-21 12:00:00.000000 + +""" + +from alembic import op + +# revision identifiers, used by Alembic. +revision = "d4e5f6a7b8c9" +down_revision = "c3d4e5f6a7b8" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + bind = op.get_bind() + # Collapse pre-existing duplicates, keeping the most complete copy (longest + # message_data == the finalized/fullest partial), tie-broken by id. Only + # Postgres can have accumulated dupes in practice; SQLite test DBs start + # clean, and DELETE…USING is Postgres-specific. + if bind.dialect.name == "postgresql": + op.execute( + """ + DELETE FROM task_messages t + USING ( + SELECT id, + ROW_NUMBER() OVER ( + PARTITION BY task_id, message_ts + ORDER BY LENGTH(message_data) DESC, id DESC + ) AS rn + FROM task_messages + WHERE message_ts IS NOT NULL + ) d + WHERE t.id = d.id AND d.rn > 1 + """ + ) + + # NULL message_ts stays distinct (legacy/backfilled rows still append). + op.create_index( + "uq_task_messages_task_ts", + "task_messages", + ["task_id", "message_ts"], + unique=True, + ) + + +def downgrade() -> None: + op.drop_index("uq_task_messages_task_ts", table_name="task_messages") diff --git a/self-hosted-cloudapi/config/settings.py b/self-hosted-cloudapi/config/settings.py index 166f691980..34a02243e4 100644 --- a/self-hosted-cloudapi/config/settings.py +++ b/self-hosted-cloudapi/config/settings.py @@ -61,8 +61,23 @@ def cors_origins_list(self) -> List[str]: # Optional features credit_system_enabled: bool = False - bridge_enabled: bool = False + # Live remote-control bridge (socket.io). When enabled the API mounts a + # socket.io server that relays events/commands between the extension and the + # web task viewer. Defaults on for self-hosted: the relay is inert until an + # extension actually connects (which itself is gated by an opt-in extension + # setting), so enabling it costs nothing when unused. + bridge_enabled: bool = True + # Path the socket.io ASGI sub-app is mounted at (the engine.io endpoint). + # The extension and browser connect with socket.io-client using this `path`. + bridge_path: str = "/bridge/socket.io" telemetry_enabled: bool = True + + # Task sharing. With no organizations configured (self-hosted single-tenant + # dev), org-level cloud settings are absent, which would leave the extension's + # Share button disabled. When true, the API advertises task sharing as enabled + # at the org-less level so a logged-in user can share tasks to the web viewer. + enable_task_sharing: bool = True + allow_public_task_sharing: bool = True rate_limit_enabled: bool = True rate_limit_requests_per_minute: int = 60 diff --git a/self-hosted-cloudapi/pyproject.toml b/self-hosted-cloudapi/pyproject.toml index 92d81d968a..fd30f1be92 100644 --- a/self-hosted-cloudapi/pyproject.toml +++ b/self-hosted-cloudapi/pyproject.toml @@ -21,6 +21,8 @@ dependencies = [ "python-multipart>=0.0.20", "pyyaml>=6.0.2", "starlette>=0.45.0", + "jinja2>=3.1.4", + "python-socketio>=5.11.0", ] [project.optional-dependencies] diff --git a/self-hosted-cloudapi/src/auth/web_session.py b/self-hosted-cloudapi/src/auth/web_session.py new file mode 100644 index 0000000000..63580d0486 --- /dev/null +++ b/self-hosted-cloudapi/src/auth/web_session.py @@ -0,0 +1,119 @@ +"""Browser session-cookie authentication for the web task viewer. + +The extension authenticates with Bearer JWTs, but a browser page needs a +cookie-based session. We mint a signed cookie after the Authentik OAuth +callback (see routers/browser.py), carrying the existing Session row id. Each +request re-validates that the Session is still active, so a cloud logoff +(/v1/client/sessions/{id}/remove, which flips Session.is_active) also +invalidates the web session. + +The cookie is signed (not encrypted) with the app secret_key via itsdangerous; +it carries no secret, only the session/user ids, and is validated server-side +against the DB on every request. +""" + +from typing import Optional, TypedDict + +from fastapi import Depends, Request +from itsdangerous import URLSafeTimedSerializer, BadSignature, SignatureExpired +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession +from starlette.responses import Response + +from config.settings import settings +from src.database import get_db +from src.models.user import Session, User + +COOKIE_NAME = "tumble_session" +_SALT = "tumble-web-session" +# 30 days — the web session stays valid until it expires or the user logs off. +MAX_AGE_SECONDS = 30 * 24 * 60 * 60 + +_serializer = URLSafeTimedSerializer(settings.secret_key, salt=_SALT) + + +class WebUser(TypedDict): + user_id: str + session_id: str + email: str + name: str + image_url: Optional[str] + + +def set_session_cookie(response: Response, session_id: str, user_id: str) -> None: + """Attach a signed session cookie to a response.""" + token = _serializer.dumps({"sid": session_id, "uid": user_id}) + # secure=False so it works over http://localhost in dev. Tighten for prod. + response.set_cookie( + key=COOKIE_NAME, + value=token, + max_age=MAX_AGE_SECONDS, + httponly=True, + samesite="lax", + secure=False, + path="/", + ) + + +def clear_session_cookie(response: Response) -> None: + """Remove the session cookie (logout).""" + response.delete_cookie(key=COOKIE_NAME, path="/") + + +def _decode_cookie(raw: str) -> Optional[dict]: + try: + return _serializer.loads(raw, max_age=MAX_AGE_SECONDS) + except (BadSignature, SignatureExpired): + return None + + +async def resolve_web_user(raw_cookie: Optional[str], db: AsyncSession) -> Optional[WebUser]: + """Validate a raw signed session cookie value against the DB → WebUser | None. + + Shared by the HTTP dependency (`get_web_user_optional`) and the socket.io + handshake, which reads the cookie from the ASGI environ rather than from a + FastAPI Request. Returns None for missing/invalid/expired cookies, + deactivated sessions, or a user that no longer exists. + """ + if not raw_cookie: + return None + + data = _decode_cookie(raw_cookie) + if not data: + return None + + session_id = data.get("sid") + if not session_id: + return None + + result = await db.execute( + select(Session).where(Session.id == session_id, Session.is_active == True) # noqa: E712 + ) + session = result.scalar_one_or_none() + if session is None: + return None + + result = await db.execute(select(User).where(User.id == session.user_id)) + user = result.scalar_one_or_none() + if user is None: + return None + + name = (f"{user.first_name or ''} {user.last_name or ''}").strip() or user.email + return WebUser( + user_id=user.id, + session_id=session.id, + email=user.email, + name=name, + image_url=user.image_url, + ) + + +async def get_web_user_optional( + request: Request, + db: AsyncSession = Depends(get_db), +) -> Optional[WebUser]: + """Resolve the current browser user from the session cookie, or None. + + Web routes redirect to /app/login on None. + """ + return await resolve_web_user(request.cookies.get(COOKIE_NAME), db) diff --git a/self-hosted-cloudapi/src/main.py b/self-hosted-cloudapi/src/main.py index 3ab794f328..c63345fd7c 100644 --- a/self-hosted-cloudapi/src/main.py +++ b/self-hosted-cloudapi/src/main.py @@ -1,13 +1,16 @@ """FastAPI application factory and lifespan management.""" from contextlib import asynccontextmanager +from pathlib import Path + from fastapi import FastAPI +from fastapi.staticfiles import StaticFiles from config.settings import settings from src.middleware.cors import setup_cors from src.middleware.request_logging import RequestLoggingMiddleware from src.middleware.rate_limit import limiter -from src.routers import auth, extension, settings as settings_router, events, marketplace, proxy, browser +from src.routers import auth, extension, settings as settings_router, events, marketplace, proxy, browser, web @asynccontextmanager @@ -80,6 +83,28 @@ async def lifespan(app: FastAPI): # LLM Proxy app.include_router(proxy.router) +# Web UI (task list + read-only task viewer) +app.include_router(web.router) + +# Static assets for the web UI (CSS, vendored JS, the renderer) +_STATIC_DIR = Path(__file__).resolve().parent / "web" / "static" +app.mount("/static", StaticFiles(directory=str(_STATIC_DIR)), name="static") + +# Live remote-control bridge (socket.io). Mounted as a sub-app so `app` stays a +# FastAPI instance (tests rely on app.dependency_overrides). The engine.io +# endpoint lands at settings.bridge_path (default /bridge/socket.io). +# +# NOTE: starlette's Mount (>=0.50) no longer strips the mount prefix from +# scope["path"]; it only adjusts root_path. engine.io matches its endpoint +# against the raw scope["path"] and ignores root_path, so socketio_path must +# include the "/bridge" prefix or every handshake falls through to a 404 — which +# crashes the WebSocket with "Expected ASGI message 'websocket.accept'...". +if settings.bridge_enabled: + import socketio + from src.realtime.sio import sio + + app.mount("/bridge", socketio.ASGIApp(sio, socketio_path="bridge/socket.io")) + @app.get("/health") async def health_check(): diff --git a/self-hosted-cloudapi/src/models/task.py b/self-hosted-cloudapi/src/models/task.py index e021985e8c..a03c1eba35 100644 --- a/self-hosted-cloudapi/src/models/task.py +++ b/self-hosted-cloudapi/src/models/task.py @@ -1,7 +1,7 @@ """Task, TaskMessage, and TaskShare models.""" import uuid -from sqlalchemy import Column, String, Text, ForeignKey, DateTime +from sqlalchemy import Column, String, Text, ForeignKey, DateTime, BigInteger, UniqueConstraint from sqlalchemy.orm import relationship from datetime import datetime, timezone @@ -27,8 +27,19 @@ class TaskMessage(Base): id = Column(String, primary_key=True, default=lambda: generate_id("msg_")) task_id = Column(String, ForeignKey("tasks.id", ondelete="CASCADE"), nullable=False, index=True) message_data = Column(Text, nullable=False) + # ClineMessage.ts of the stored message. Lets the live bridge upsert a + # streaming message in place (created → partial updates → final) instead of + # appending duplicate rows. Nullable for legacy/backfilled rows. + message_ts = Column(BigInteger, nullable=True, index=True) created_at = Column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)) + # The bridge upserts a streaming message in place via ON CONFLICT on this + # pair. NULL message_ts stays distinct, so legacy/backfilled rows still + # append. See migration d4e5f6a7b8c9. + __table_args__ = ( + UniqueConstraint("task_id", "message_ts", name="uq_task_messages_task_ts"), + ) + task = relationship("Task", back_populates="messages") diff --git a/self-hosted-cloudapi/src/realtime/__init__.py b/self-hosted-cloudapi/src/realtime/__init__.py new file mode 100644 index 0000000000..8880a6bdba --- /dev/null +++ b/self-hosted-cloudapi/src/realtime/__init__.py @@ -0,0 +1 @@ +"""Live remote-control bridge (socket.io relay).""" diff --git a/self-hosted-cloudapi/src/realtime/hub.py b/self-hosted-cloudapi/src/realtime/hub.py new file mode 100644 index 0000000000..c186e9f89f --- /dev/null +++ b/self-hosted-cloudapi/src/realtime/hub.py @@ -0,0 +1,84 @@ +"""In-memory connection registry for the remote-control bridge. + +Pure bookkeeping with no socket.io / I/O dependency so it can be unit-tested +directly. The socket.io handlers in `sio.py` are thin glue over this. + +Two kinds of sockets connect, both authenticated to a `user_id`: +- **extension** sockets — at most one live instance per user is tracked (the + most recently registered wins); commands are relayed to its `sid`. +- **browser** sockets — subscribe to task rooms; events are relayed to them by + socket.io room, so the registry only needs their per-sid metadata. + +Pairing a browser to an extension is by shared `user_id` (the same identity on +both auth paths), which is what makes the relay safe and simple. +""" + +from __future__ import annotations + +import time +from typing import Optional, TypedDict + + +class SocketMeta(TypedDict): + role: str # "extension" | "browser" + user_id: str + + +class ConnectionRegistry: + def __init__(self) -> None: + # sid -> {role, user_id} + self._meta: dict[str, SocketMeta] = {} + # user_id -> extension sid (newest registered instance wins) + self._ext_sid_by_user: dict[str, str] = {} + # user_id -> last registered/updated ExtensionInstance-ish dict + self._instance_by_user: dict[str, dict] = {} + + # --- generic socket metadata ------------------------------------------ + + def attach(self, sid: str, role: str, user_id: str) -> None: + self._meta[sid] = SocketMeta(role=role, user_id=user_id) + + def meta(self, sid: str) -> Optional[SocketMeta]: + return self._meta.get(sid) + + def detach(self, sid: str) -> Optional[SocketMeta]: + """Remove a socket; if it was the user's registered extension, clear it.""" + meta = self._meta.pop(sid, None) + if meta and meta["role"] == "extension": + uid = meta["user_id"] + if self._ext_sid_by_user.get(uid) == sid: + self._ext_sid_by_user.pop(uid, None) + self._instance_by_user.pop(uid, None) + return meta + + # --- extension instance ----------------------------------------------- + + def register_extension(self, sid: str, user_id: str, instance: Optional[dict] = None) -> None: + self._ext_sid_by_user[user_id] = sid + inst = dict(instance or {}) + inst["lastHeartbeat"] = time.time() + self._instance_by_user[user_id] = inst + + def heartbeat(self, user_id: str) -> None: + inst = self._instance_by_user.get(user_id) + if inst is not None: + inst["lastHeartbeat"] = time.time() + + def update_instance_state(self, user_id: str, state: dict) -> None: + """Merge a live instance_state snapshot into the stored instance.""" + inst = self._instance_by_user.setdefault(user_id, {}) + inst.update(state) + inst["lastHeartbeat"] = time.time() + + def extension_sid(self, user_id: str) -> Optional[str]: + return self._ext_sid_by_user.get(user_id) + + def instance(self, user_id: str) -> Optional[dict]: + return self._instance_by_user.get(user_id) + + def has_extension(self, user_id: str) -> bool: + return user_id in self._ext_sid_by_user + + +# Process-wide singleton (the API runs as a single instance for self-hosted). +registry = ConnectionRegistry() diff --git a/self-hosted-cloudapi/src/realtime/sio.py b/self-hosted-cloudapi/src/realtime/sio.py new file mode 100644 index 0000000000..05e33f1567 --- /dev/null +++ b/self-hosted-cloudapi/src/realtime/sio.py @@ -0,0 +1,240 @@ +"""socket.io server: the live remote-control relay. + +Topology (the backend is always in the middle — no direct VS Code ↔ browser link): + + extension --(extension:register / task:event)--> server --(task:relayed_event)--> browser + browser --(task:command)--> server --(task:relayed_command)--> extension + +Auth happens once, at the socket.io handshake (`connect`): +- extension: presents a session JWT in the handshake `auth.token` (fetched from + /api/extension/bridge/config). Validated with `decode_token`. +- browser: presents the signed `tumble_session` cookie (sent automatically on a + same-origin connection). Validated with `resolve_web_user`. + +Both resolve to a `user_id`; a browser may only join/drive tasks it owns, and a +command is relayed only to that same user's extension socket. +""" + +import logging +from typing import Optional + +import socketio +from sqlalchemy import select + +from config.settings import settings +from src.auth.jwt_issuer import decode_token +from src.auth.static_token import validate_static_token +from src.auth.web_session import COOKIE_NAME, resolve_web_user +from src.database import async_session_factory +from src.models.task import Task +from src.services.telemetry_service import upsert_task_message +from src.realtime.hub import registry + +logger = logging.getLogger(__name__) + +# Event names — mirror the TS enums in packages/types/src/cloud.ts. +EXT_REGISTER = "extension:register" +EXT_UNREGISTER = "extension:unregister" +EXT_HEARTBEAT = "extension:heartbeat" + +TASK_JOIN = "task:join" +TASK_LEAVE = "task:leave" +TASK_EVENT = "task:event" # from extension +TASK_RELAYED_EVENT = "task:relayed_event" # to browsers +TASK_COMMAND = "task:command" # from browser +TASK_RELAYED_COMMAND = "task:relayed_command" # to extension + +# Bridge event `type` discriminators (TaskBridgeEventName). +EVT_MESSAGE = "message" +EVT_INSTANCE_STATE = "instanceState" + + +def _create_server() -> socketio.AsyncServer: + origins = settings.cors_origins_list + return socketio.AsyncServer( + async_mode="asgi", + # "*" disables the Origin check; a concrete list restricts it. + cors_allowed_origins="*" if origins == ["*"] else origins, + logger=False, + engineio_logger=False, + ) + + +sio = _create_server() + + +def _room(task_id: str) -> str: + return f"task:{task_id}" + + +def _user_id_from_token(token: Optional[str]) -> Optional[str]: + """Resolve a handshake bearer/JWT/static token to a user_id, or None.""" + if not token: + return None + static_result = validate_static_token(token) + if static_result is not None: + return static_result.get("user_id") + payload = decode_token(token) + if payload is None: + return None + return payload.get("r", {}).get("u") or payload.get("sub") + + +def _cookie_from_environ(environ: dict) -> Optional[str]: + """Extract the tumble_session cookie value from the ASGI handshake environ.""" + raw_cookie_header = environ.get("HTTP_COOKIE", "") + if not raw_cookie_header: + return None + for part in raw_cookie_header.split(";"): + name, _, value = part.strip().partition("=") + if name == COOKIE_NAME: + return value + return None + + +async def _user_owns_task(user_id: str, task_id: str) -> bool: + if not task_id: + return False + async with async_session_factory() as db: + result = await db.execute( + select(Task.id).where(Task.id == task_id, Task.user_id == user_id) + ) + return result.scalar_one_or_none() is not None + + +# --- lifecycle ------------------------------------------------------------ + + +@sio.event +async def connect(sid, environ, auth): + """Authenticate the handshake and tag the socket with its role + user_id. + + Returning False rejects the connection. + """ + auth = auth or {} + token = auth.get("token") + + if token: + user_id = _user_id_from_token(token) + if not user_id: + logger.info("[bridge] extension handshake rejected: invalid token") + return False + registry.attach(sid, "extension", user_id) + return True + + # No token → browser; authenticate via the session cookie. + async with async_session_factory() as db: + web_user = await resolve_web_user(_cookie_from_environ(environ), db) + if web_user is None: + logger.info("[bridge] browser handshake rejected: no valid session") + return False + registry.attach(sid, "browser", web_user["user_id"]) + return True + + +@sio.event +async def disconnect(sid): + registry.detach(sid) + + +# --- extension → server --------------------------------------------------- + + +@sio.on(EXT_REGISTER) +async def on_extension_register(sid, data): + meta = registry.meta(sid) + if not meta or meta["role"] != "extension": + return {"success": False, "error": "not an extension socket"} + registry.register_extension(sid, meta["user_id"], data if isinstance(data, dict) else {}) + return {"success": True} + + +@sio.on(EXT_HEARTBEAT) +async def on_extension_heartbeat(sid, data=None): + meta = registry.meta(sid) + if meta and meta["role"] == "extension": + registry.heartbeat(meta["user_id"]) + return {"success": True} + + +@sio.on(EXT_UNREGISTER) +async def on_extension_unregister(sid, data=None): + registry.detach(sid) + return {"success": True} + + +@sio.on(TASK_EVENT) +async def on_task_event(sid, data): + """An event from the extension's task: relay to browsers + persist messages.""" + meta = registry.meta(sid) + if not meta or meta["role"] != "extension": + return + if not isinstance(data, dict): + return + task_id = data.get("taskId") + if not task_id: + return + + # Relay to every browser watching this task. + await sio.emit(TASK_RELAYED_EVENT, data, room=_room(task_id)) + + user_id = meta["user_id"] + evt_type = data.get("type") + + if evt_type == EVT_INSTANCE_STATE: + registry.update_instance_state(user_id, data) + + if evt_type == EVT_MESSAGE and isinstance(data.get("message"), dict): + try: + async with async_session_factory() as db: + await upsert_task_message(db, task_id, user_id, data["message"]) + await db.commit() + except Exception as exc: # persistence must never break the live relay + logger.warning("[bridge] failed to persist task message: %s", exc) + + +# --- browser → server ----------------------------------------------------- + + +@sio.on(TASK_JOIN) +async def on_task_join(sid, data): + meta = registry.meta(sid) + if not meta: + return {"success": False, "error": "unauthenticated"} + task_id = (data or {}).get("taskId") + if not await _user_owns_task(meta["user_id"], task_id): + return {"success": False, "error": "forbidden"} + await sio.enter_room(sid, _room(task_id)) + instance = registry.instance(meta["user_id"]) + return { + "success": True, + "taskId": task_id, + "instanceOnline": registry.has_extension(meta["user_id"]), + "instance": instance, + } + + +@sio.on(TASK_LEAVE) +async def on_task_leave(sid, data): + task_id = (data or {}).get("taskId") + if task_id: + await sio.leave_room(sid, _room(task_id)) + return {"success": True} + + +@sio.on(TASK_COMMAND) +async def on_task_command(sid, data): + """A command from the browser: relay only to that user's extension socket.""" + meta = registry.meta(sid) + if not meta or meta["role"] != "browser": + return {"success": False, "error": "not a browser socket"} + if not isinstance(data, dict): + return {"success": False, "error": "bad payload"} + task_id = data.get("taskId") + if not await _user_owns_task(meta["user_id"], task_id): + return {"success": False, "error": "forbidden"} + ext_sid = registry.extension_sid(meta["user_id"]) + if not ext_sid: + return {"success": False, "error": "extension offline"} + await sio.emit(TASK_RELAYED_COMMAND, data, to=ext_sid) + return {"success": True} diff --git a/self-hosted-cloudapi/src/routers/browser.py b/self-hosted-cloudapi/src/routers/browser.py index 8c81af48d5..d4b0b0dfd1 100644 --- a/self-hosted-cloudapi/src/routers/browser.py +++ b/self-hosted-cloudapi/src/routers/browser.py @@ -18,6 +18,7 @@ from src.database import get_db from src.auth.authentik import generate_pkce_pair, get_authorize_url +from src.auth.web_session import set_session_cookie, clear_session_cookie from src.services.auth_service import ( store_oauth_state, get_oauth_state, @@ -28,6 +29,11 @@ from src.auth.authentik import exchange_code_for_tokens, get_userinfo from config.settings import settings +# Marker stored as the OAuth `auth_redirect` for browser (web) logins. The +# shared /auth/clerk/callback branches on this: web logins set a session cookie +# and redirect to /app; everything else does the vscode:// bounce. +WEB_AUTH_REDIRECT = "web:/app" + logger = logging.getLogger(__name__) router = APIRouter(tags=["browser-auth"]) @@ -191,6 +197,32 @@ async def landing_page( return RedirectResponse(url=authorize_url) +@router.get("/app/login") +async def web_login( + db: AsyncSession = Depends(get_db), +): + """Start the Authentik OAuth flow for a browser (web viewer) login. + + Uses the same redirect URI as the extension flow; the callback distinguishes + web logins via the WEB_AUTH_REDIRECT marker stored in the OAuth state. + """ + state = secrets.token_urlsafe(32) + code_verifier, code_challenge = generate_pkce_pair() + await store_oauth_state(db, state, WEB_AUTH_REDIRECT, code_verifier) + authorize_url = get_authorize_url( + state=state, code_challenge=code_challenge, auth_redirect=WEB_AUTH_REDIRECT + ) + return RedirectResponse(url=authorize_url) + + +@router.get("/app/logout") +async def web_logout(): + """Clear the browser session cookie and return to the login page.""" + response = RedirectResponse(url="/app/login", status_code=303) + clear_session_cookie(response) + return response + + @router.get("/auth/clerk/callback") async def auth_callback( code: str = Query(...), @@ -274,6 +306,18 @@ async def auth_callback( # created here would be unrecoverable). session = await create_session(db, user.id) + # Browser (web viewer) login: set a signed session cookie and redirect to + # the task list instead of bouncing back to VS Code. + if state_store.auth_redirect == WEB_AUTH_REDIRECT: + logger.info( + "Web auth callback successful for user %s (email=%s)", + authentik_id[:8] if authentik_id else "unknown", + email, + ) + response = RedirectResponse(url="/app", status_code=303) + set_session_cookie(response, session_id=session.id, user_id=user.id) + return response + # Generate ticket for Clerk sign-in flow ticket_code = await create_ticket(db, session.id) diff --git a/self-hosted-cloudapi/src/routers/extension.py b/self-hosted-cloudapi/src/routers/extension.py index da8867e3d3..37eacb6a52 100644 --- a/self-hosted-cloudapi/src/routers/extension.py +++ b/self-hosted-cloudapi/src/routers/extension.py @@ -19,19 +19,37 @@ router = APIRouter(prefix="/api/extension", tags=["extension"]) -@router.post("/share") +# response_model_exclude_none is REQUIRED: the client parses this body with the +# Zod shareResponseSchema (packages/types/src/cloud.ts) whose optional fields use +# `.optional()`, which accepts `undefined` but REJECTS `null`. On the success path +# `error` (and any other unset Optional) would otherwise serialize as JSON `null`, +# the Zod parse in CloudAPI.shareTask would throw, and the extension would show +# "Failed to share task" even though the task persisted and the share row exists. +# Same contract as /api/extension-settings (see routers/settings.py). +@router.post("/share", response_model_exclude_none=True) async def share_task_endpoint( body: ShareTaskRequest, current_user: dict = Depends(get_current_user), db: AsyncSession = Depends(get_db), ) -> ShareResponse: - """Share a task.""" + """Share a task. + + Returns HTTP 404 when the task does not exist yet. The extension relies on + this: on a 404 (TaskNotFoundError) it backfills the task messages via + /api/events/backfill and retries this endpoint. Returning 200 with + success=false would skip that backfill, so the task would never persist. + """ result = await share_task( db=db, task_id=body.task_id, user_id=current_user["user_id"], visibility=body.visibility, ) + if not result.success and (result.error or "").lower() == "task not found": + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Task not found", + ) return result diff --git a/self-hosted-cloudapi/src/routers/settings.py b/self-hosted-cloudapi/src/routers/settings.py index 81fb22dce8..59bde17a36 100644 --- a/self-hosted-cloudapi/src/routers/settings.py +++ b/self-hosted-cloudapi/src/routers/settings.py @@ -20,7 +20,13 @@ router = APIRouter(prefix="/api", tags=["settings"]) -@router.get("/extension-settings") +# NOTE: response_model_exclude_none is REQUIRED here. The client parses this +# response with Zod schemas (packages/types cloud.ts) whose optional fields use +# `.optional()` — which accepts `undefined` but REJECTS `null`. Pydantic would +# otherwise serialize unset Optional fields as JSON `null`, the client parse would +# fail, CloudSettingsService never caches the settings, and `canShareTask()` returns +# false — silently disabling the Share button. Omitting nulls keeps the contract. +@router.get("/extension-settings", response_model_exclude_none=True) async def extension_settings_endpoint( current_user: dict = Depends(get_current_user), db: AsyncSession = Depends(get_db), @@ -33,7 +39,10 @@ async def extension_settings_endpoint( ) -@router.patch("/user-settings") +# Same null-vs-undefined contract as /extension-settings: the client parses this +# with the strict `.optional()` userSettingsDataSchema, so unset fields must be +# omitted rather than serialized as null. +@router.patch("/user-settings", response_model_exclude_none=True) async def update_user_settings_endpoint( body: UpdateUserSettingsRequest, current_user: dict = Depends(get_current_user), diff --git a/self-hosted-cloudapi/src/routers/web.py b/self-hosted-cloudapi/src/routers/web.py new file mode 100644 index 0000000000..66f7a95fe3 --- /dev/null +++ b/self-hosted-cloudapi/src/routers/web.py @@ -0,0 +1,244 @@ +"""Web task viewer router. + +Server-rendered pages (Jinja2) for browsing shared tasks in a browser: +- GET /app task list for the logged-in user +- GET /app/tasks/{task_id} read-only conversation view (owner only) +- GET /shared/{task_id} public share-link target (anon if visibility=public) + +Login/logout live in routers/browser.py (/app/login, /app/logout) because they +reuse the Authentik OAuth flow there. Conversation rendering is done client-side +by static/render.js from the embedded ClineMessage[] JSON. +""" + +import json +import logging +from pathlib import Path +from typing import Optional + +from fastapi import APIRouter, Depends, Request +from fastapi.responses import HTMLResponse, RedirectResponse +from fastapi.templating import Jinja2Templates +from sqlalchemy import select, func +from sqlalchemy.ext.asyncio import AsyncSession + +from config.settings import settings +from src.database import get_db +from src.auth.web_session import WebUser, get_web_user_optional +from src.models.task import Task, TaskMessage, TaskShare +from src.services.share_service import delete_shared_task + +logger = logging.getLogger(__name__) + +_WEB_DIR = Path(__file__).resolve().parent.parent / "web" +templates = Jinja2Templates(directory=str(_WEB_DIR / "templates")) + + +def _asset_version() -> str: + """Cache-busting token: newest mtime across the static bundle. + + Appended as ``?v=`` to CSS/JS URLs so a browser refetches the + assets whenever they change instead of serving a stale cached copy + (the page HTML is dynamic, but ``/static/*`` is otherwise cached hard). + Recomputed at import — the server is restarted to pick up edits. + """ + static_dir = _WEB_DIR / "static" + try: + latest = max(p.stat().st_mtime_ns for p in static_dir.rglob("*") if p.is_file()) + except ValueError: + return "0" + return format(latest, "x") + + +templates.env.globals["asset_v"] = _asset_version() + +router = APIRouter(tags=["web"]) + +# Message says/asks whose text is the most representative task title. +_TITLE_MAX = 100 + + +def _derive_title(messages: list[dict]) -> str: + """Pick a human-readable title from the conversation (first text-bearing msg).""" + for msg in messages: + text = (msg.get("text") or "").strip() + if text and not text.startswith("{"): + first_line = text.splitlines()[0].strip() + if first_line: + return first_line[:_TITLE_MAX] + ("…" if len(first_line) > _TITLE_MAX else "") + return "Untitled task" + + +def _parse_messages(rows: list[TaskMessage]) -> list[dict]: + """Decode and sort stored TaskMessage rows into ClineMessage dicts.""" + parsed: list[dict] = [] + for row in rows: + try: + data = json.loads(row.message_data) + except (json.JSONDecodeError, TypeError): + continue + if isinstance(data, dict): + parsed.append(data) + parsed.sort(key=lambda m: m.get("ts", 0)) + return parsed + + +async def _load_task_messages(db: AsyncSession, task_id: str) -> list[dict]: + result = await db.execute( + select(TaskMessage).where(TaskMessage.task_id == task_id) + ) + return _parse_messages(list(result.scalars().all())) + + +@router.get("/app", response_class=HTMLResponse) +async def task_list( + request: Request, + user: Optional[WebUser] = Depends(get_web_user_optional), + db: AsyncSession = Depends(get_db), +): + """List the logged-in user's shared tasks.""" + if user is None: + return RedirectResponse(url="/app/login", status_code=303) + + # Tasks owned by the user, newest first, with message counts. + count_sq = ( + select(TaskMessage.task_id, func.count(TaskMessage.id).label("n")) + .group_by(TaskMessage.task_id) + .subquery() + ) + result = await db.execute( + select(Task, count_sq.c.n) + .outerjoin(count_sq, count_sq.c.task_id == Task.id) + .where(Task.user_id == user["user_id"]) + .order_by(Task.updated_at.desc()) + ) + + items = [] + for task, n in result.all(): + messages = await _load_task_messages(db, task.id) + items.append( + { + "id": task.id, + "title": _derive_title(messages), + "message_count": n or 0, + "updated_at": task.updated_at, + } + ) + + return templates.TemplateResponse( + request, + "tasks_list.html", + {"user": user, "tasks": items}, + ) + + +@router.get("/app/tasks/{task_id}", response_class=HTMLResponse) +async def task_detail( + task_id: str, + request: Request, + user: Optional[WebUser] = Depends(get_web_user_optional), + db: AsyncSession = Depends(get_db), +): + """Read-only conversation view for a task the user owns.""" + if user is None: + return RedirectResponse(url="/app/login", status_code=303) + + result = await db.execute(select(Task).where(Task.id == task_id)) + task = result.scalar_one_or_none() + if task is None or task.user_id != user["user_id"]: + return templates.TemplateResponse( + request, + "not_found.html", + {"user": user}, + status_code=404, + ) + + messages = await _load_task_messages(db, task_id) + # The owner view is live: it can drive the task through the socket.io bridge + # (extension ↔ backend ↔ browser). Disabled when the bridge is off. + live = settings.bridge_enabled + return templates.TemplateResponse( + request, + "task_detail.html", + { + "user": user, + "task": task, + "title": _derive_title(messages), + "messages_json": json.dumps(messages), + "share_url": None, + "live": live, + "can_delete": True, + "live_config_json": json.dumps({"taskId": task_id, "bridgePath": settings.bridge_path}), + }, + ) + + +@router.post("/app/tasks/{task_id}/delete") +async def delete_task( + task_id: str, + user: Optional[WebUser] = Depends(get_web_user_optional), + db: AsyncSession = Depends(get_db), +): + """Permanently delete a task the user owns (row + messages + share). + + Owner-only; a non-owner / unknown id is a silent no-op (see + ``delete_shared_task``). Always redirects back to the task list, so the + POST is idempotent and refresh-safe. + """ + if user is None: + return RedirectResponse(url="/app/login", status_code=303) + + await delete_shared_task(db, task_id, user["user_id"]) + return RedirectResponse(url="/app", status_code=303) + + +@router.get("/shared/{task_id}", response_class=HTMLResponse) +async def shared_task( + task_id: str, + request: Request, + user: Optional[WebUser] = Depends(get_web_user_optional), + db: AsyncSession = Depends(get_db), +): + """Public share-link target. Anonymous when visibility=public, else requires login.""" + result = await db.execute(select(TaskShare).where(TaskShare.task_id == task_id)) + share = result.scalar_one_or_none() + + if share is None: + return templates.TemplateResponse( + request, + "not_found.html", + {"user": user}, + status_code=404, + ) + + if share.visibility != "public" and user is None: + # Organization/private share viewed anonymously → require login. + return RedirectResponse(url="/app/login", status_code=303) + + # The share link is live (remote-controllable) only for the task's owner — so a + # freshly-shared task is drivable straight from its share URL. Anonymous and + # non-owner viewers stay strictly read-only. The backend independently enforces + # the same owner-only rule (task:join DB ownership check + per-user command relay). + task_result = await db.execute(select(Task).where(Task.id == task_id)) + task = task_result.scalar_one_or_none() + is_owner = user is not None and task is not None and task.user_id == user["user_id"] + live = bool(settings.bridge_enabled and is_owner) + + messages = await _load_task_messages(db, task_id) + return templates.TemplateResponse( + request, + "task_detail.html", + { + "user": user, + "task": {"id": task_id}, + "title": _derive_title(messages), + "messages_json": json.dumps(messages), + "share_url": share.share_url, + "live": live, + "can_delete": is_owner, + "live_config_json": ( + json.dumps({"taskId": task_id, "bridgePath": settings.bridge_path}) + if live + else "{}" + ), + }, + ) diff --git a/self-hosted-cloudapi/src/services/bridge_service.py b/self-hosted-cloudapi/src/services/bridge_service.py index b1759bf6d0..38c8334151 100644 --- a/self-hosted-cloudapi/src/services/bridge_service.py +++ b/self-hosted-cloudapi/src/services/bridge_service.py @@ -1,15 +1,25 @@ -"""Bridge service for WebSocket bridge config.""" +"""Bridge service: config the extension needs to open the socket.io connection. + +The extension fetches this from GET /api/extension/bridge/config, then connects +its socket.io client to `socketBridgeUrl` (origin) using `socketBridgePath` +(the mounted engine.io endpoint) and `token` (a short-lived session JWT) for the +handshake auth. The server validates that token to bind the socket to a user. +""" from config.settings import settings from src.auth.jwt_issuer import issue_session_token -async def get_bridge_config(user_id: str, org_id: str = None): - """Get bridge/websocket configuration.""" +async def get_bridge_config(user_id: str, org_id: str = None) -> dict: + """Build the socket.io bridge configuration for an extension instance.""" + # Short-lived token: the extension re-fetches the config (and so a fresh + # token) whenever it (re)connects. token = issue_session_token(user_id, org_id, expires_in=300) return { "userId": user_id, - "socketBridgeUrl": f"ws://localhost:8080/ws" if settings.bridge_enabled else "", + # Origin of this API; the socket.io client appends `socketBridgePath`. + "socketBridgeUrl": settings.api_base_url, + "socketBridgePath": settings.bridge_path, "token": token, } diff --git a/self-hosted-cloudapi/src/services/settings_service.py b/self-hosted-cloudapi/src/services/settings_service.py index 4cbb22b232..f16658300b 100644 --- a/self-hosted-cloudapi/src/services/settings_service.py +++ b/self-hosted-cloudapi/src/services/settings_service.py @@ -1,10 +1,12 @@ """Settings service for extension-settings and user-settings endpoints.""" +import hashlib import json from typing import Optional from sqlalchemy.ext.asyncio import AsyncSession +from config.settings import settings as app_settings from src.services.user_service import get_or_create_org_settings, get_or_create_user_settings from src.schemas.settings import ( OrganizationSettingsResponse, @@ -17,6 +19,20 @@ ) +def _content_version(payload: dict) -> int: + """A stable, content-derived settings version (32-bit int). + + The extension caches org settings and only replaces them when the `version` + field changes (CloudSettingsService.fetchSettings). A constant version means + a client that cached the old org-less settings (e.g. cloudSettings=null from + before task sharing was advertised) never picks up the new values. Deriving + the version from the content guarantees any change to the advertised cloud + settings bumps the version, so the client refreshes on its next fetch. + """ + blob = json.dumps(payload, sort_keys=True).encode() + return int.from_bytes(hashlib.sha256(blob).digest()[:4], "big") + + async def get_extension_settings( db: AsyncSession, user_id: str, @@ -24,7 +40,21 @@ async def get_extension_settings( ) -> ExtensionSettingsResponse: """Get combined org + user settings for the /api/extension-settings endpoint.""" # Organization settings - org_settings_response = OrganizationSettingsResponse() + # + # With no organization configured (self-hosted single-tenant), there are no + # org-level cloud settings. The extension gates its Share button on + # `organization.cloudSettings.enableTaskSharing`, so without this the button + # stays disabled ("sharingDisabledByOrganization"). Advertise task sharing as + # enabled at the org-less level, controlled by ENABLE_TASK_SHARING. The + # version is content-derived so already-logged-in clients pick up the change. + org_cloud_settings = OrganizationCloudSettings( + enable_task_sharing=app_settings.enable_task_sharing, + allow_public_task_sharing=app_settings.allow_public_task_sharing, + ) + org_settings_response = OrganizationSettingsResponse( + version=_content_version(org_cloud_settings.model_dump(by_alias=True)), + cloud_settings=org_cloud_settings, + ) if org_id: org_settings = await get_or_create_org_settings(db, org_id) allow_list = json.loads(org_settings.allow_list) if org_settings.allow_list else {"allowAll": True, "providers": {}} diff --git a/self-hosted-cloudapi/src/services/share_service.py b/self-hosted-cloudapi/src/services/share_service.py index 0018b8addd..3a9fca4b4d 100644 --- a/self-hosted-cloudapi/src/services/share_service.py +++ b/self-hosted-cloudapi/src/services/share_service.py @@ -4,9 +4,10 @@ from typing import Optional from sqlalchemy.ext.asyncio import AsyncSession -from sqlalchemy import select +from sqlalchemy import select, delete -from src.models.task import Task, TaskShare +from config.settings import settings +from src.models.task import Task, TaskMessage, TaskShare from src.schemas.share import ShareResponse @@ -30,27 +31,65 @@ async def share_task( ) existing_share = result.scalar_one_or_none() + # Absolute URLs so the link the extension copies to the clipboard is + # directly openable in a browser. + base = settings.api_base_url.rstrip("/") + share_url = f"{base}/shared/{task_id}" + manage_url = f"{base}/app/tasks/{task_id}" + if existing_share: + # Refresh visibility and (legacy relative) URLs to the absolute form. + existing_share.visibility = visibility + existing_share.share_url = share_url + existing_share.manage_url = manage_url + await db.flush() return ShareResponse( success=True, - share_url=existing_share.share_url, + share_url=share_url, is_new_share=False, - manage_url=existing_share.manage_url, + manage_url=manage_url, ) # Create new share share = TaskShare( task_id=task_id, visibility=visibility, - share_url=f"/shared/{task_id}", - manage_url=f"/manage/{task_id}", + share_url=share_url, + manage_url=manage_url, ) db.add(share) await db.flush() return ShareResponse( success=True, - share_url=share.share_url, + share_url=share_url, is_new_share=True, - manage_url=share.manage_url, + manage_url=manage_url, ) + + +async def delete_shared_task( + db: AsyncSession, + task_id: str, + user_id: str, +) -> bool: + """Permanently remove a task and everything hanging off it from the DB. + + Returns True when the task existed and was owned by ``user_id`` (and is now + gone), False otherwise — so an unknown id or another user's task is a safe + no-op, never a leak or an error. + + Children are deleted explicitly (messages, then shares, then the task) + rather than via ORM relationship cascade: under async SQLAlchemy the cascade + would try to lazy-load ``task.messages``/``task.shares``, which raises. + """ + result = await db.execute(select(Task).where(Task.id == task_id)) + task = result.scalar_one_or_none() + if task is None or task.user_id != user_id: + return False + + await db.execute(delete(TaskMessage).where(TaskMessage.task_id == task_id)) + await db.execute(delete(TaskShare).where(TaskShare.task_id == task_id)) + await db.execute(delete(Task).where(Task.id == task_id)) + await db.flush() + return True diff --git a/self-hosted-cloudapi/src/services/telemetry_service.py b/self-hosted-cloudapi/src/services/telemetry_service.py index b9b9510dcf..e9186e88e1 100644 --- a/self-hosted-cloudapi/src/services/telemetry_service.py +++ b/self-hosted-cloudapi/src/services/telemetry_service.py @@ -30,13 +30,126 @@ async def backfill_messages( user_id: str, messages: list, ) -> None: - """Backfill task messages.""" - from src.models.task import TaskMessage + """Backfill task messages. + + Ensures the parent Task row exists (owned by the uploading user) before + inserting messages — TaskMessage.task_id is a FK to tasks.id, so without + this the insert raises an IntegrityError. Idempotent: re-uploading a task + (e.g. re-sharing after more turns) replaces the previously stored messages + rather than appending duplicates. + """ + from sqlalchemy import select, delete + from src.models.task import Task, TaskMessage + + # Get-or-create the parent task, owned by the uploading user. + result = await db.execute(select(Task).where(Task.id == task_id)) + task = result.scalar_one_or_none() + if task is None: + task = Task(id=task_id, user_id=user_id) + db.add(task) + await db.flush() + + # Replace any existing messages for this task (idempotent re-share). + await db.execute(delete(TaskMessage).where(TaskMessage.task_id == task_id)) for msg in messages: + ts = msg.get("ts") if isinstance(msg, dict) else None task_msg = TaskMessage( task_id=task_id, message_data=json.dumps(msg) if not isinstance(msg, str) else msg, + message_ts=ts, ) db.add(task_msg) await db.flush() + + +async def upsert_task_message( + db: AsyncSession, + task_id: str, + user_id: str, + message: dict, +) -> None: + """Insert or update a single live-streamed task message. + + Used by the remote-control bridge: a ClineMessage streams through several + states (created → partial updates → final) under one `ts`. We get-or-create + the parent Task (so a live task becomes visible in the web list) and upsert + the row keyed by (task_id, ts) so the read-only history mirrors the live view + instead of accumulating duplicate partial rows. + + The collapse is done with a dialect-native `INSERT … ON CONFLICT DO UPDATE` + on the `(task_id, message_ts)` unique index. A non-atomic SELECT-then-write + raced under rapid partial events (streaming reasoning), leaving duplicate + `partial:true` rows that the finalizing update could never clean up. + + The `DO UPDATE` is **monotonic** so a streamed message can only advance + toward its most-complete form. Without a guard, the concurrent per-event + transactions for one `ts` serialize on the unique-index row lock and the + *last to commit* wins — non-deterministically an early, short partial — + freezing the row at truncated text + `partial:true`. The web view then shows + only the opening words of a reasoning trace (e.g. "The user says"). + + The guard: + - A **final** message (`partial` falsy) is authoritative and always wins. It + carries the full accumulated text, and there is exactly one per `ts`. + - A **partial** may only overwrite when its payload is at least as long as + the stored one. Streamed `partial:true` chunks carry the *accumulated* + text (`_reasoningMessage += chunk`), so their `message_data` grows + monotonically — a late, short partial is rejected and can never clobber a + fuller payload or a finalize already in place. + + (Length only fails as a key across the partial→final boundary, where the + final drops the `"partial":true"` flag and can be a few bytes shorter despite + longer text — which is exactly why finals bypass the length check.) + """ + from sqlalchemy import func, select + from src.models.task import Task, TaskMessage + + if not isinstance(message, dict): + return + + ts = message.get("ts") + payload = json.dumps(message) + + result = await db.execute(select(Task).where(Task.id == task_id)) + task = result.scalar_one_or_none() + if task is None: + task = Task(id=task_id, user_id=user_id) + db.add(task) + await db.flush() + elif task.user_id != user_id: + # Never let a bridge event write into another user's task. + return + + dialect = db.bind.dialect.name + if ts is not None and dialect in ("postgresql", "sqlite"): + if dialect == "postgresql": + from sqlalchemy.dialects.postgresql import insert as _insert + else: + from sqlalchemy.dialects.sqlite import insert as _insert + + is_final = not message.get("partial") + base = _insert(TaskMessage).values( + task_id=task_id, message_data=payload, message_ts=ts + ) + on_conflict = dict( + index_elements=["task_id", "message_ts"], + set_={"message_data": base.excluded.message_data}, + ) + if not is_final: + # A partial may only advance the row, never shrink it, so a + # late-committing early partial can't clobber a fuller payload. A + # final bypasses this (authoritative, one per ts) — it may legitimately + # be a few bytes shorter than the last partial once `partial:true` is + # dropped. + on_conflict["where"] = func.length(base.excluded.message_data) >= func.length( + TaskMessage.message_data + ) + stmt = base.on_conflict_do_update(**on_conflict) + await db.execute(stmt) + await db.flush() + return + + # ts is None (legacy/backfill) or an exotic dialect: just append. + db.add(TaskMessage(task_id=task_id, message_data=payload, message_ts=ts)) + await db.flush() diff --git a/self-hosted-cloudapi/src/web/static/app.css b/self-hosted-cloudapi/src/web/static/app.css new file mode 100644 index 0000000000..f295130385 --- /dev/null +++ b/self-hosted-cloudapi/src/web/static/app.css @@ -0,0 +1,614 @@ +:root { + --bg: #1e1e1e; + --panel: #252526; + --panel-2: #2d2d2d; + --border: #3a3a3a; + --text: #d4d4d4; + --muted: #9a9a9a; + --accent: #4ec9b0; + --blue: #0078d4; + --user: #2b3b52; + --error: #f44747; + --command: #1f2d24; + --radius: 8px; + --mono: "JetBrains Mono", "SF Mono", Menlo, Consolas, monospace; + --sans: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; +} + +* { + box-sizing: border-box; +} + +body { + margin: 0; + background: var(--bg); + color: var(--text); + font-family: var(--sans); + line-height: 1.55; +} + +a { + color: var(--accent); + text-decoration: none; +} +a:hover { + text-decoration: underline; +} + +.topbar { + display: flex; + align-items: center; + gap: 0.75rem; + padding: 0.7rem 1.25rem; + background: var(--panel); + border-bottom: 1px solid var(--border); + position: sticky; + top: 0; + z-index: 10; +} +.brand { + font-weight: 700; + color: var(--text); + font-size: 1.05rem; +} +.brand:hover { + text-decoration: none; +} +.brand-sub { + color: var(--accent); + font-weight: 600; + font-size: 0.8rem; +} +.spacer { + flex: 1; +} +.user { + color: var(--muted); + font-size: 0.9rem; +} + +.btn { + display: inline-block; + padding: 0.4rem 0.9rem; + background: var(--blue); + color: #fff; + border-radius: 6px; + font-weight: 600; + font-size: 0.85rem; +} +.btn:hover { + background: #1a8ae8; + text-decoration: none; +} +.btn.ghost { + background: transparent; + border: 1px solid var(--border); + color: var(--muted); +} +.btn.ghost:hover { + background: var(--panel-2); +} + +.content { + max-width: 920px; + margin: 0 auto; + padding: 1.5rem 1.25rem 4rem; +} +.page-title { + font-size: 1.4rem; + margin: 0 0 1rem; +} + +/* Task list */ +.task-list { + list-style: none; + padding: 0; + margin: 0; + display: flex; + flex-direction: column; + gap: 0.5rem; +} +.task-item { + display: flex; + align-items: stretch; + gap: 0.5rem; +} +.task-link { + flex: 1; + min-width: 0; + display: flex; + align-items: center; + justify-content: space-between; + gap: 1rem; + padding: 0.85rem 1rem; + background: var(--panel); + border: 1px solid var(--border); + border-radius: var(--radius); + color: var(--text); +} +.task-link:hover { + background: var(--panel-2); + text-decoration: none; + border-color: #4a4a4a; +} +.task-title { + font-weight: 500; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.task-meta { + display: flex; + align-items: center; + gap: 0.6rem; + flex-shrink: 0; +} +.badge { + background: var(--panel-2); + color: var(--muted); + font-size: 0.72rem; + padding: 0.12rem 0.5rem; + border-radius: 999px; + border: 1px solid var(--border); +} +.task-date { + color: var(--muted); + font-size: 0.78rem; +} + +.empty { + text-align: center; + color: var(--muted); + padding: 4rem 1rem; +} +.empty-hint { + font-size: 0.9rem; +} + +/* Delete (task list row + detail header) */ +.task-delete { + display: flex; +} +.task-delete-detail { + margin: 0; +} +.btn-delete { + height: 100%; + display: inline-flex; + align-items: center; + padding: 0.4rem 0.9rem; + background: transparent; + color: var(--muted); + border: 1px solid var(--border); + border-radius: var(--radius); + font-family: var(--sans); + font-weight: 600; + font-size: 0.82rem; + cursor: pointer; + transition: + color 0.12s, + border-color 0.12s, + background 0.12s; +} +.btn-delete:hover { + color: var(--error); + border-color: var(--error); + background: #3a2323; +} + +/* Detail */ +.detail-header { + margin-bottom: 1.25rem; +} +.detail-title-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: 1rem; +} +.detail-title-row .page-title { + margin: 0; +} +.back { + font-size: 0.85rem; + color: var(--muted); +} +.share-note { + color: var(--accent); + font-size: 0.78rem; + margin-top: 0.25rem; +} +.loading { + color: var(--muted); + padding: 2rem 0; +} + +.conversation { + display: flex; + flex-direction: column; + gap: 0.75rem; +} + +.msg { + border: 1px solid var(--border); + border-radius: var(--radius); + background: var(--panel); + overflow: hidden; +} +.msg-head { + display: flex; + align-items: center; + gap: 0.5rem; + padding: 0.45rem 0.8rem; + font-size: 0.72rem; + text-transform: uppercase; + letter-spacing: 0.04em; + color: var(--muted); + border-bottom: 1px solid var(--border); + background: var(--panel-2); +} +.msg-icon { + font-size: 0.85rem; +} +.msg-body { + padding: 0.7rem 0.9rem; +} +.msg-body > *:first-child { + margin-top: 0; +} +.msg-body > *:last-child { + margin-bottom: 0; +} + +.msg.role-user { + border-color: #3a4d6b; +} +.msg.role-user .msg-head { + background: var(--user); + color: #cdddf2; +} +.msg.role-assistant .msg-head { + color: var(--accent); +} +.msg.role-completion { + border-color: var(--accent); +} +.msg.role-completion .msg-head { + background: #1f3b34; + color: var(--accent); +} +.msg.role-command .msg-head { + color: #b5cea8; +} +.msg.role-command .msg-body { + background: var(--command); +} +.msg.role-output .msg-body { + background: #181818; +} +.msg.role-error { + border-color: var(--error); +} +.msg.role-error .msg-head { + color: var(--error); +} +.msg.role-api .msg-head, +.msg.role-system .msg-head, +.msg.role-mcp .msg-head { + color: var(--muted); +} +.msg.role-reasoning .msg-body { + color: var(--muted); + font-style: italic; +} + +/* Compact rows whose whole payload lives in the head label (e.g. an in-flight + API request with no folded body) collapse to a single line. */ +.msg:not(:has(.msg-body)) .msg-head { + border-bottom: none; +} + +/* "Executing now" — spinner on the streaming row + header activity pill. */ +.msg.running { + border-color: var(--blue); +} +.spinner { + display: inline-block; + width: 0.7rem; + height: 0.7rem; + margin-left: 0.4rem; + border: 2px solid var(--border); + border-top-color: var(--accent); + border-radius: 50%; + animation: spin 0.7s linear infinite; + vertical-align: -1px; +} +@keyframes spin { + to { + transform: rotate(360deg); + } +} + +.ask-resolution { + font-size: 0.7rem; + font-weight: 600; + padding: 0.05rem 0.45rem; + border-radius: 999px; + text-transform: none; + letter-spacing: 0; +} +.ask-resolution.approved { + background: #1f3b34; + color: var(--accent); +} +.ask-resolution.denied { + background: #3a2323; + color: var(--error); +} + +/* Right-aligned per-row meta: timestamp + step duration. */ +.msg-meta { + margin-left: auto; + display: inline-flex; + align-items: center; + gap: 0.4rem; + flex-shrink: 0; + font-size: 0.68rem; + text-transform: none; + letter-spacing: 0; + color: var(--muted); +} +.msg-time { + white-space: nowrap; +} +.msg-dur { + color: var(--accent); + white-space: nowrap; +} + +/* Inline approval on the ask's own row, in chronological position. */ +.msg.ask-pending { + border-color: #6b5d2f; + box-shadow: 0 0 0 1px #6b5d2f inset; +} +.ask-actions-inline { + display: flex; + gap: 0.5rem; + justify-content: flex-end; + padding: 0.55rem 0.8rem; + border-top: 1px solid var(--border); + background: #2a2a1f; +} + +pre { + background: #161616; + border: 1px solid var(--border); + border-radius: 6px; + padding: 0.7rem 0.85rem; + overflow-x: auto; +} +code { + font-family: var(--mono); + font-size: 0.85rem; +} +:not(pre) > code { + background: #161616; + padding: 0.1rem 0.35rem; + border-radius: 4px; +} +pre code { + background: none; + padding: 0; +} + +.path { + font-family: var(--mono); + font-size: 0.8rem; + color: var(--accent); + margin-bottom: 0.4rem; +} +.kv { + color: var(--muted); + font-size: 0.8rem; +} + +details { + font-size: 0.85rem; +} +details summary { + cursor: pointer; + color: var(--muted); +} +details[open] summary { + margin-bottom: 0.5rem; +} + +/* Foldable rows: the is the header — one collapsible line. */ +.msg.foldable > details > summary.msg-head { + list-style: none; + cursor: pointer; + border-bottom: none; + margin-bottom: 0; +} +.msg.foldable > details > summary.msg-head::-webkit-details-marker { + display: none; +} +/* Disclosure triangle on the LEFT so the timestamp stays at the block's right edge. */ +.msg.foldable > details > summary.msg-head::before { + content: "▸"; + margin-right: 0.1rem; + font-size: 0.7rem; + color: var(--muted); +} +.msg.foldable > details[open] > summary.msg-head { + border-bottom: 1px solid var(--border); + margin-bottom: 0; +} +.msg.foldable > details[open] > summary.msg-head::before { + content: "▾"; +} + +.img-msg img { + max-width: 100%; + border-radius: 6px; + border: 1px solid var(--border); +} + +/* --- live remote control (owner task view) ------------------------------- */ +.live-header { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 0.6rem 1.1rem; + padding: 0.55rem 0.85rem; + margin-bottom: 0.9rem; + background: var(--panel); + border: 1px solid var(--border); + border-radius: var(--radius); + font-size: 0.82rem; + color: var(--muted); +} +.live-stat b { + color: var(--text); + font-weight: 600; +} +.live-status { + font-size: 0.72rem; + text-transform: uppercase; + letter-spacing: 0.05em; + padding: 0.18rem 0.55rem; + border-radius: 999px; + border: 1px solid var(--border); + background: var(--panel-2); + color: var(--muted); +} +.live-status.live { + background: #1f3b34; + color: var(--accent); + border-color: var(--accent); +} +.live-status.offline { + background: #3a2323; + color: var(--error); + border-color: var(--error); +} + +.live-activity { + display: inline-flex; + align-items: center; + gap: 0.4rem; + font-size: 0.78rem; + color: var(--accent); +} +.live-activity.busy::before { + content: ""; + width: 0.55rem; + height: 0.55rem; + border-radius: 50%; + background: var(--accent); + animation: pulse 1.1s ease-in-out infinite; +} +@keyframes pulse { + 0%, + 100% { + opacity: 1; + } + 50% { + opacity: 0.25; + } +} + +.live-controls { + margin-top: 1rem; + padding: 0.85rem; + background: var(--panel); + border: 1px solid var(--border); + border-radius: var(--radius); + display: flex; + flex-direction: column; + gap: 0.75rem; +} +.live-controls.offline { + opacity: 0.65; +} +.auto-bar { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 0.6rem 1rem; + font-size: 0.82rem; + color: var(--muted); +} +.auto-bar label { + display: inline-flex; + align-items: center; + gap: 0.3rem; + cursor: pointer; +} +.auto-main { + font-weight: 600; + color: var(--text); +} +.auto-toggles { + display: flex; + flex-wrap: wrap; + gap: 0.5rem 0.9rem; +} +.auto-mode-wrap select { + background: var(--panel-2); + color: var(--text); + border: 1px solid var(--border); + border-radius: 6px; + padding: 0.2rem 0.4rem; + font-family: var(--sans); +} +.chat-bar { + display: flex; + gap: 0.6rem; + align-items: stretch; +} +.chat-bar textarea { + flex: 1; + resize: vertical; + min-height: 2.4rem; + background: var(--panel-2); + color: var(--text); + border: 1px solid var(--border); + border-radius: var(--radius); + padding: 0.55rem 0.7rem; + font-family: var(--sans); + font-size: 0.9rem; +} +.chat-actions { + display: flex; + flex-direction: column; + gap: 0.4rem; +} +.btn-approve { + background: #1f3b34; + color: var(--accent); + border: 1px solid var(--accent); +} +.btn-deny { + background: #3a2323; + color: var(--error); + border: 1px solid var(--error); +} +.btn-send { + background: var(--blue); + color: #fff; +} +.btn-stop { + background: transparent; + color: var(--error); + border: 1px solid var(--error); +} +.btn-resume { + background: transparent; + color: var(--accent); + border: 1px solid var(--accent); +} +.btn:disabled { + opacity: 0.45; + cursor: not-allowed; +} diff --git a/self-hosted-cloudapi/src/web/static/live.js b/self-hosted-cloudapi/src/web/static/live.js new file mode 100644 index 0000000000..ec430fedbc --- /dev/null +++ b/self-hosted-cloudapi/src/web/static/live.js @@ -0,0 +1,337 @@ +/* + * Live remote-control controller for an owned Tumble Code task. + * + * Loaded only on the owner's task page (never on /shared). Connects to the + * backend socket.io relay with the browser session cookie, joins the task room, + * appends relayed `message` events to the conversation already rendered by + * render.js, and drives the task by emitting `task:command` events. All traffic + * is browser ↔ backend ↔ extension — there is no direct link to VS Code. + * + * Protocol mirrors packages/types/src/cloud.ts (TaskSocketEvents / TaskBridge*). + */ +;(function () { + "use strict" + + var cfgEl = document.getElementById("live-config") + if (!cfgEl || typeof io === "undefined") return + + var cfg + try { + cfg = JSON.parse(cfgEl.textContent || "{}") + } catch (e) { + return + } + if (!cfg.taskId) return + + var taskId = cfg.taskId + var bridgePath = cfg.bridgePath || "/bridge/socket.io" + + // --- DOM handles --------------------------------------------------------- + var els = { + status: document.getElementById("live-status"), + activity: document.getElementById("live-activity"), + tokensIn: document.getElementById("hdr-tokens-in"), + tokensOut: document.getElementById("hdr-tokens-out"), + context: document.getElementById("hdr-context"), + mode: document.getElementById("hdr-mode"), + cost: document.getElementById("hdr-cost"), + input: document.getElementById("chat-input"), + send: document.getElementById("btn-send"), + stop: document.getElementById("btn-stop"), + resume: document.getElementById("btn-resume"), + autoEnabled: document.getElementById("auto-enabled"), + autoMode: document.getElementById("auto-mode"), + controls: document.getElementById("live-controls"), + } + var toggles = {} + ;["ReadOnly", "Write", "Execute", "Mcp", "ModeSwitch", "Subtasks"].forEach(function (k) { + toggles[k] = document.getElementById("auto-" + k) + }) + + // --- helpers ------------------------------------------------------------- + function fmt(n) { + if (n == null) return "—" + return Number(n).toLocaleString() + } + + function setStatus(text, cls) { + if (!els.status) return + els.status.textContent = text + els.status.className = "live-status " + (cls || "") + } + + function setControlsEnabled(online) { + if (els.controls) els.controls.classList.toggle("offline", !online) + ;[els.send, els.stop, els.input, els.autoEnabled, els.autoMode].forEach(function (el) { + if (el) el.disabled = !online + }) + Object.keys(toggles).forEach(function (k) { + if (toggles[k]) toggles[k].disabled = !online + }) + // Resume stays available when offline so the user can reopen the task. + if (els.resume) els.resume.disabled = false + } + + // --- socket -------------------------------------------------------------- + var socket = io(window.location.origin, { + path: bridgePath, + withCredentials: true, + transports: ["websocket", "polling"], + }) + + var convo = null + function getConvo() { + if (!convo) convo = window.__tumbleConversation || null + return convo + } + + var lastAsk = null // currentAsk from the most recent instanceState + var isRunning = false + // Token/cost/context are derived from the persisted conversation so a finished + // or offline task still shows its totals (no live instanceState ever arrives + // for it). A live instanceState, when one does arrive, is authoritative and + // takes over via haveLiveTokens. contextWindow is live-only. + var haveLiveTokens = false + var lastContextWindow = null + + function applyMetrics() { + if (haveLiveTokens) return // live instanceState owns these while running + var c = getConvo() + if (!c || !c.getMetrics) return + var mm = c.getMetrics() + if (els.tokensIn) els.tokensIn.textContent = fmt(mm.totalTokensIn) + if (els.tokensOut) els.tokensOut.textContent = fmt(mm.totalTokensOut) + if (els.cost) els.cost.textContent = mm.totalCost > 0 ? "$" + Number(mm.totalCost).toFixed(4) : "—" + if (els.context) { + els.context.textContent = mm.contextTokens + ? fmt(mm.contextTokens) + (lastContextWindow ? " / " + fmt(lastContextWindow) : "") + : "—" + } + } + + // Single source of truth for the Stop⇄Resume toggle. The task is "running" + // only while it is actively streaming or blocked on an interactive approval + // (the extension derives this from taskStatus). Anything else — idle, + // resumable, completed, or offline — shows Resume, never Stop. + function setRunning(running) { + isRunning = !!running + if (els.stop) els.stop.style.display = isRunning ? "" : "none" + if (els.resume) els.resume.style.display = isRunning ? "none" : "" + refreshActivity() + } + + // Surface what the task is doing right now (api / tool / thinking), like the + // VS Code webview. Prefer the live streaming row; fall back to isRunning. + function refreshActivity() { + if (!els.activity) return + var c = getConvo() + var label = c && c.getActivity ? c.getActivity() : null + if (!label && isRunning) label = "Working…" + if (label) { + els.activity.textContent = label + els.activity.style.display = "" + els.activity.classList.add("busy") + } else { + els.activity.textContent = "" + els.activity.style.display = "none" + els.activity.classList.remove("busy") + } + } + + socket.on("connect", function () { + socket.emit("task:join", { taskId: taskId }, function (res) { + if (!res || !res.success) { + setStatus("Cannot control this task", "offline") + setControlsEnabled(false) + return + } + var online = !!res.instanceOnline + setStatus(online ? "Live" : "Extension offline", online ? "live" : "offline") + setControlsEnabled(online) + if (!online) setRunning(false) + if (res.instance) applyInstanceState(res.instance) + }) + }) + + socket.on("disconnect", function () { + setStatus("Disconnected", "offline") + setControlsEnabled(false) + setRunning(false) + }) + + socket.on("connect_error", function () { + setStatus("Connection error", "offline") + }) + + socket.on("task:relayed_event", function (data) { + if (!data || typeof data !== "object") return + if (data.type === "message" && data.message) { + var c = getConvo() + if (c) c.upsert(data.message) + refreshActivity() + applyMetrics() + } else if (data.type === "instanceState") { + applyInstanceState(data) + } + }) + + // --- render instance state into the header + controls -------------------- + function applyInstanceState(s) { + if (!s) return + if (s.contextWindow) lastContextWindow = s.contextWindow + var tu = s.tokenUsage || {} + // A snapshot carrying token data means the task is live — let it own the + // header totals from here on, over the message-derived baseline. + if (tu.totalTokensIn != null || tu.totalTokensOut != null || tu.totalCost != null) { + haveLiveTokens = true + } + if (els.tokensIn) els.tokensIn.textContent = fmt(tu.totalTokensIn) + if (els.tokensOut) els.tokensOut.textContent = fmt(tu.totalTokensOut) + if (els.cost && tu.totalCost != null) els.cost.textContent = "$" + Number(tu.totalCost).toFixed(4) + var ctx = s.contextTokens != null ? s.contextTokens : tu.contextTokens + if (els.context) { + els.context.textContent = + ctx != null ? fmt(ctx) + (s.contextWindow ? " / " + fmt(s.contextWindow) : "") : "—" + } + if (els.mode && s.mode) els.mode.textContent = s.mode + + if (s.isRunning != null) { + setRunning(s.isRunning) + } + if (s.autoApproval) applyAutoApproval(s.autoApproval) + if ("currentAsk" in s) { + lastAsk = s.currentAsk || null + updateAskFromState(lastAsk) + } + refreshActivity() + } + + var answered = {} // ask ts -> true: a stale instanceState must not re-show it + + // Drive the inline Approve/Deny on the ask's own conversation row. + function updateAskFromState(ask) { + var c = getConvo() + if (!c || !c.setActiveAsk) return + if (ask && ask.ts != null && !answered[ask.ts]) { + c.setActiveAsk(ask.ts, { + onApprove: function () { + answerAsk(ask, "approved") + }, + onDeny: function () { + answerAsk(ask, "denied") + }, + }) + } else { + c.clearActiveAsk() + } + } + + function answerAsk(ask, decision) { + if (ask && ask.ts != null) answered[ask.ts] = true + sendCommand(decision === "approved" ? "approve_ask" : "deny_ask", { payload: {} }) + var c = getConvo() + if (c && ask) c.markResolved(ask.ts, decision) + lastAsk = null + } + + var suppressAuto = false + function applyAutoApproval(a) { + suppressAuto = true + try { + if (els.autoEnabled && a.autoApprovalEnabled != null) els.autoEnabled.checked = !!a.autoApprovalEnabled + if (els.autoMode && a.autoApprovalMode) els.autoMode.value = a.autoApprovalMode + var map = { + ReadOnly: a.alwaysAllowReadOnly, + Write: a.alwaysAllowWrite, + Execute: a.alwaysAllowExecute, + Mcp: a.alwaysAllowMcp, + ModeSwitch: a.alwaysAllowModeSwitch, + Subtasks: a.alwaysAllowSubtasks, + } + Object.keys(map).forEach(function (k) { + if (toggles[k] && map[k] != null) toggles[k].checked = !!map[k] + }) + } finally { + suppressAuto = false + } + } + + // --- command emitters ---------------------------------------------------- + function sendCommand(type, extra) { + var payload = Object.assign({ type: type, taskId: taskId, timestamp: Date.now() }, extra || {}) + socket.emit("task:command", payload, function (res) { + if (!res || !res.success) { + setStatus( + (res && res.error) === "extension offline" ? "Extension offline" : "Command failed", + "offline", + ) + } + }) + } + + if (els.send) { + els.send.addEventListener("click", function () { + var text = ((els.input && els.input.value) || "").trim() + if (!text) return + sendCommand("message", { payload: { text: text } }) + if (els.input) els.input.value = "" + }) + } + if (els.input) { + els.input.addEventListener("keydown", function (e) { + if (e.key === "Enter" && (e.ctrlKey || e.metaKey)) { + e.preventDefault() + if (els.send) els.send.click() + } + }) + } + if (els.stop) + els.stop.addEventListener("click", function () { + sendCommand("stop_task", {}) + }) + if (els.resume) + els.resume.addEventListener("click", function () { + sendCommand("resume_task", {}) + }) + + function pushAutoApproval() { + if (suppressAuto) return + var payload = {} + if (els.autoEnabled) payload.autoApprovalEnabled = !!els.autoEnabled.checked + if (els.autoMode) payload.autoApprovalMode = els.autoMode.value + var keyByName = { + ReadOnly: "alwaysAllowReadOnly", + Write: "alwaysAllowWrite", + Execute: "alwaysAllowExecute", + Mcp: "alwaysAllowMcp", + ModeSwitch: "alwaysAllowModeSwitch", + Subtasks: "alwaysAllowSubtasks", + } + Object.keys(toggles).forEach(function (k) { + if (toggles[k]) payload[keyByName[k]] = !!toggles[k].checked + }) + sendCommand("set_auto_approval", { payload: payload }) + } + ;[els.autoEnabled, els.autoMode].forEach(function (el) { + if (el) el.addEventListener("change", pushAutoApproval) + }) + Object.keys(toggles).forEach(function (k) { + if (toggles[k]) toggles[k].addEventListener("change", pushAutoApproval) + }) + + // Initial UI state: offline and not-running until the join ack says otherwise. + setStatus("Connecting…", "") + setControlsEnabled(false) + setRunning(false) + + // Baseline the header from the already-rendered history so a finished/offline + // task shows its totals immediately, before (or without) any live snapshot. + // render.js calls this once the conversation is mounted; also try inline in + // case render.js already finished (readyState was not "loading"). + window.TumbleLiveInit = function (c) { + convo = c || convo + applyMetrics() + refreshActivity() + } + applyMetrics() +})() diff --git a/self-hosted-cloudapi/src/web/static/render.js b/self-hosted-cloudapi/src/web/static/render.js new file mode 100644 index 0000000000..8d6ac5fe19 --- /dev/null +++ b/self-hosted-cloudapi/src/web/static/render.js @@ -0,0 +1,560 @@ +/* + * Lightweight read-only renderer for a Tumble Code task conversation. + * + * Input: a ClineMessage[] (packages/types/src/message.ts) embedded as JSON in + * #messages-data. Each message is a {type:"ask"|"say", ask?, say?, text?, ...}. + * We render a vertical list of rows — the same flow as the extension chat — + * classifying each message into a role for styling. Markdown is rendered with + * marked and sanitized with DOMPurify (content can come from public shares). + */ +;(function () { + "use strict" + + marked.setOptions({ breaks: true, gfm: true }) + + function md(text) { + if (!text) return "" + return DOMPurify.sanitize(marked.parse(String(text))) + } + + function escapeHtml(s) { + return String(s == null ? "" : s) + .replace(/&/g, "&") + .replace(//g, ">") + } + + function fmtTime(ts) { + const d = new Date(Number(ts)) + if (isNaN(d)) return "" + return d.toLocaleString() + } + + function fmtDuration(ms) { + if (ms == null || ms < 0) return "" + if (ms < 1000) return Math.round(ms) + "ms" + const s = ms / 1000 + if (s < 60) return (s < 10 ? s.toFixed(1) : Math.round(s)) + "s" + const m = Math.floor(s / 60) + const r = Math.round(s % 60) + return m + "m " + r + "s" + } + + function firstLine(text, max) { + const line = String(text == null ? "" : text).split("\n")[0] + return line.length > max ? line.slice(0, max - 1) + "…" : line + } + + function tryParse(text) { + if (typeof text !== "string") return null + const t = text.trim() + if (!t.startsWith("{") && !t.startsWith("[")) return null + try { + return JSON.parse(t) + } catch (e) { + return null + } + } + + function codeBlock(content, lang) { + return "
" + escapeHtml(content) + "
" + } + + // Returns { role, label, icon, body(html) } or null to skip the message. + function classify(m) { + const kind = m.say || m.ask || m.type + + // Messages with no renderable payload. + if (kind === "api_req_finished") return null + + switch (kind) { + case "user_feedback": + case "user_feedback_diff": + return { role: "user", label: "You", icon: "\u{1F464}", body: md(m.text) } + + case "text": + if (!m.text && !(m.images && m.images.length)) return null + return { + role: "assistant", + label: "Assistant", + icon: "\u{1F916}", + body: md(m.text) + images(m), + fold: true, + activity: "Responding…", + } + + case "reasoning": + if (!m.text && !m.reasoning) return null + return { + role: "reasoning", + label: "Reasoning", + icon: "\u{1F4AD}", + body: md(m.text || m.reasoning), + fold: true, + activity: "Thinking…", + } + + case "completion_result": + // The result `say` carries the text; the trailing empty `ask` would + // otherwise render a redundant "Task completed." row — drop it. + if (!m.text) return null + return { role: "completion", label: "Result", icon: "✅", body: md(m.text) } + + case "command": + return { + role: "command", + label: "Command · " + firstLine(m.text, 80), + icon: "\u{1F4BB}", + body: codeBlock(m.text || ""), + fold: true, + activity: "Running command…", + } + + case "command_output": + if (!m.text) return null + return { role: "output", label: "Output", icon: "≡", body: codeBlock(m.text), fold: true } + + case "error": + case "diff_error": + case "rooignore_error": + case "mistake_limit_reached": + case "api_req_failed": + return { role: "error", label: "Error", icon: "⚠", body: md(m.text) || "An error occurred." } + + case "api_req_started": + return apiReq(m) + + case "tool": + return toolMsg(m) + + case "followup": + return followup(m) + + case "use_mcp_server": + case "mcp_server_request_started": + case "mcp_server_response": + if (!m.text) return null + return { role: "mcp", label: "MCP", icon: "\u{1F50C}", body: renderMaybeJson(m.text), fold: true } + + case "checkpoint_saved": + return { + role: "system", + label: "Checkpoint", + icon: "\u{1F4CD}", + body: "Checkpoint saved", + } + + case "condense_context": + return { + role: "system", + label: "Context condensed", + icon: "\u{1F5DC}", + body: + m.contextCondense && m.contextCondense.summary + ? md(m.contextCondense.summary) + : "Conversation context was summarized.", + } + + case "subtask_result": + return { role: "completion", label: "Subtask result", icon: "↳", body: md(m.text) } + + case "image": + return { role: "assistant", label: "Image", icon: "\u{1F5BC}", body: images(m) } + + default: + if (!m.text) return null + return { role: "system", label: kind || "Message", icon: "ℹ", body: renderMaybeJson(m.text) } + } + } + + function images(m) { + if (!m.images || !m.images.length) return "" + return ( + '
' + + m.images + .map(function (src) { + return 'attachment' + }) + .join("") + + "
" + ) + } + + function renderMaybeJson(text) { + const obj = tryParse(text) + if (obj) return codeBlock(JSON.stringify(obj, null, 2)) + return md(text) + } + + function apiReq(m) { + const obj = tryParse(m.text) || {} + const bits = [] + if (obj.tokensIn != null || obj.tokensOut != null) { + bits.push("↑" + (obj.tokensIn || 0) + " ↓" + (obj.tokensOut || 0)) + } + if (obj.cost != null) bits.push("$" + Number(obj.cost).toFixed(4)) + // One-liner: stats live in the row label; the body holds only the optional + // folded request prompt. No cost yet → the request is still in flight. + const label = "API request" + (bits.length ? " · " + bits.join(" · ") : "") + const body = obj.request ? md(obj.request) : "" + const active = obj.cost == null && obj.cancelReason == null && obj.streamingFailedMessage == null + return { + role: "api", + label: label, + icon: "⇅", + body: body, + fold: !!body, + active: active, + activity: "Calling API…", + } + } + + function toolMsg(m) { + const obj = tryParse(m.text) + if (!obj) + return { + role: "tool", + label: "Tool", + icon: "\u{1F527}", + body: md(m.text), + fold: true, + activity: "Running tool…", + } + const name = obj.tool || "tool" + let inner = "" + if (obj.path) inner += '
' + escapeHtml(obj.path) + "
" + if (obj.diff) inner += codeBlock(obj.diff, "diff") + else if (obj.content) inner += codeBlock(obj.content) + else if (obj.query) inner += '
query: ' + escapeHtml(obj.query) + "
" + if (!inner) inner = codeBlock(JSON.stringify(obj, null, 2)) + const label = "Tool · " + name + (obj.path ? " · " + obj.path : "") + return { role: "tool", label: label, icon: "\u{1F527}", body: inner, fold: true, activity: "Running tool…" } + } + + function followup(m) { + const obj = tryParse(m.text) + let body + if (obj && obj.question) { + body = md(obj.question) + const sug = obj.suggest || obj.suggestions + if (Array.isArray(sug) && sug.length) { + body += + "
    " + + sug + .map(function (s) { + const txt = typeof s === "string" ? s : (s && s.answer) || "" + return "
  • " + escapeHtml(txt) + "
  • " + }) + .join("") + + "
" + } + } else { + body = md(m.text) + } + return { role: "assistant", label: "Question", icon: "❓", body: body } + } + + function rowEl(info, ts, active) { + const el = document.createElement("div") + el.className = "msg role-" + info.role + (active ? " running" : "") + (info.fold ? " foldable" : "") + if (ts != null) el.setAttribute("data-ts", String(ts)) + const spinner = active ? '' : "" + // Right-aligned meta: absolute time (+ step duration, backfilled later). + const time = ts != null ? '' + escapeHtml(fmtTime(ts)) + "" : "" + const meta = '' + time + '' + const headInner = '' + info.icon + "" + escapeHtml(info.label) + spinner + meta + if (info.fold && info.body) { + // The summary IS the header — one collapsible line that expands in place, + // instead of a header row stacked on a redundant "Show…" summary. + el.innerHTML = + '
' + + headInner + + "" + + '
' + + info.body + + "
" + } else { + // A true one-liner when there is no body (e.g. an in-flight API request). + const bodyHtml = info.body ? '
' + info.body + "
" : "" + el.innerHTML = '
' + headInner + "
" + bodyHtml + } + return el + } + + // Badge an answered ask row so the reader can see the decision after the fact. + function resolutionBadge(decision) { + const span = document.createElement("span") + span.className = "ask-resolution " + decision + span.textContent = decision === "approved" ? "✓ Approved" : decision === "denied" ? "✗ Denied" : "✓ Answered" + return span + } + + // A live-updatable conversation: renders rows keyed by message `ts` so a + // streaming message (created → partial → final, all one ts) replaces its row + // in place instead of appending duplicates — mirroring the live VS Code view. + function mountConversation(container) { + const byTs = {} + const rawByTs = {} // ts -> latest raw message, for token/cost metrics + const activeByTs = {} // ts -> activity label, for the "executing now" indicator + const resolvedByTs = {} // ts -> "approved"|"denied", survives row replacement + let activeAsk = null // { ts, onApprove, onDeny, ... } — the pending approval + let tail = null // { ts, el } — last row in document order, for step duration + let count = 0 + + function clearPlaceholder() { + const empty = container.querySelector(".empty, .loading") + if (empty) empty.remove() + container.removeAttribute("aria-busy") + } + + function metaOf(el) { + return el && (el.querySelector(".msg-meta") || el.querySelector(".msg-head")) + } + + function applyResolution(el, decision) { + if (!el || el.querySelector(".ask-resolution")) return + const meta = metaOf(el) + if (meta) meta.appendChild(resolutionBadge(decision)) + } + + function setDuration(el, ms) { + const d = el && el.querySelector(".msg-dur") + if (d && !d.textContent) d.textContent = " · " + fmtDuration(ms) + } + + function copyDuration(from, to) { + const a = from && from.querySelector(".msg-dur") + const b = to && to.querySelector(".msg-dur") + if (a && b && a.textContent) b.textContent = a.textContent + } + + // Attach Approve/Deny to the ask's own conversation row (chronological, + // coherent) instead of a detached bar. Buttons stop propagation so they + // never toggle the row's fold. + function decorateAsk(el) { + if (!el || !activeAsk || resolvedByTs[el.getAttribute("data-ts")]) return + el.classList.add("ask-pending") + if (el.querySelector(".ask-actions-inline")) return + const bar = document.createElement("div") + bar.className = "ask-actions-inline" + const spec = activeAsk + const mkBtn = function (cls, text, fn) { + const b = document.createElement("button") + b.type = "button" + b.className = "btn " + cls + b.textContent = text + b.addEventListener("click", function (e) { + e.preventDefault() + e.stopPropagation() + fn() + }) + return b + } + bar.appendChild( + mkBtn("btn-approve", spec.approveLabel || "Approve", function () { + spec.onApprove && spec.onApprove() + }), + ) + if (spec.showDeny !== false) { + bar.appendChild( + mkBtn("btn-deny", spec.denyLabel || "Deny", function () { + spec.onDeny && spec.onDeny() + }), + ) + } + el.appendChild(bar) + } + + function undecorateAsk(el) { + if (!el) return + el.classList.remove("ask-pending") + const bar = el.querySelector(".ask-actions-inline") + if (bar) bar.remove() + } + + function upsert(m, opts) { + if (!m || typeof m !== "object") return + if (m.partial && !m.text && !(m.images && m.images.length)) return + const info = classify(m) + if (!info) return + clearPlaceholder() + const ts = m.ts + // A row is "running" while its message streams (partial) or, for an API + // request, until it reports a cost. The in-place upsert of the final + // message clears it automatically. Initial history replay (opts.history) + // is a point-in-time snapshot, not a live stream — never animate it, or a + // partial row persisted mid-stream would spin forever. A later live event + // for the same ts re-activates it and the finalize clears it. + const active = !(opts && opts.history) && (!!m.partial || !!info.active) + if (ts != null) { + rawByTs[ts] = m + if (active) activeByTs[ts] = info.activity || info.label + else delete activeByTs[ts] + } + const fresh = rowEl(info, ts, active) + const existing = ts != null ? byTs[ts] : null + if (existing && existing.parentNode) { + copyDuration(existing, fresh) + existing.parentNode.replaceChild(fresh, existing) + if (tail && tail.ts === ts) tail.el = fresh + } else { + // New step: the previous tail's duration is now known (gap to this ts). + if (tail && tail.ts != null && ts != null && ts >= tail.ts) { + setDuration(tail.el, ts - tail.ts) + } + container.appendChild(fresh) + count++ + if (ts != null) tail = { ts: ts, el: fresh } + } + if (ts != null) { + byTs[ts] = fresh + if (resolvedByTs[ts]) applyResolution(fresh, resolvedByTs[ts]) + else if (activeAsk && activeAsk.ts === ts) decorateAsk(fresh) + } + } + + // Show inline Approve/Deny on the ask row. `spec` carries the handlers. + function setActiveAsk(ts, spec) { + if (ts == null) { + clearActiveAsk() + return + } + if (activeAsk && activeAsk.ts !== ts) clearActiveAsk() + if (resolvedByTs[ts]) return + activeAsk = Object.assign({ ts: ts }, spec || {}) + decorateAsk(byTs[ts]) + } + + function clearActiveAsk() { + if (activeAsk) undecorateAsk(byTs[activeAsk.ts]) + activeAsk = null + } + + // Mark an answered ask (approve/deny) so the decision stays visible. + function markResolved(ts, decision) { + if (ts == null) return + resolvedByTs[ts] = decision + delete activeByTs[ts] + if (activeAsk && activeAsk.ts === ts) activeAsk = null + undecorateAsk(byTs[ts]) + applyResolution(byTs[ts], decision) + } + + // Token/cost summary derived from the persisted conversation — the same + // aggregation the VS Code view uses (consolidateTokenUsage): sum tokens/cost + // over api_req_started (+ condense_context cost); contextTokens is the last + // request's tokensIn+tokensOut (tokensIn already includes cache tokens). + function getMetrics() { + const m = { totalTokensIn: 0, totalTokensOut: 0, totalCost: 0, contextTokens: 0 } + const tss = Object.keys(rawByTs) + .map(Number) + .sort(function (a, b) { + return a - b + }) + tss.forEach(function (ts) { + const msg = rawByTs[ts] + if (!msg || msg.type !== "say") return + if (msg.say === "api_req_started" && msg.text) { + const o = tryParse(msg.text) + if (!o) return + if (typeof o.tokensIn === "number") m.totalTokensIn += o.tokensIn + if (typeof o.tokensOut === "number") m.totalTokensOut += o.tokensOut + if (typeof o.cost === "number") m.totalCost += o.cost + } else if (msg.say === "condense_context" && msg.contextCondense) { + m.totalCost += msg.contextCondense.cost || 0 + } + }) + for (let i = tss.length - 1; i >= 0; i--) { + const msg = rawByTs[tss[i]] + if (!msg || msg.type !== "say") continue + if (msg.say === "api_req_started" && msg.text) { + const o = tryParse(msg.text) + if (o) { + m.contextTokens = (o.tokensIn || 0) + (o.tokensOut || 0) + } + } else if (msg.say === "condense_context" && msg.contextCondense) { + m.contextTokens = msg.contextCondense.newContextTokens || 0 + } + if (m.contextTokens) break + } + return m + } + + // Label of the newest still-active row, or null when idle. + function getActivity() { + let best = null + Object.keys(activeByTs).forEach(function (ts) { + if (best == null || Number(ts) > best) best = Number(ts) + }) + return best == null ? null : activeByTs[best] + } + + function renderAll(messages) { + ;(messages || []).forEach(function (m) { + upsert(m, { history: true }) + }) + if (count === 0) { + container.innerHTML = '
This task has no messages.
' + } + } + + return { + upsert: upsert, + renderAll: renderAll, + markResolved: markResolved, + setActiveAsk: setActiveAsk, + clearActiveAsk: clearActiveAsk, + getActivity: getActivity, + getMetrics: getMetrics, + get count() { + return count + }, + } + } + + // Exposed so the live controller (live.js) can reuse the exact same rendering. + window.TumbleConversation = { mount: mountConversation } + + function localizeDates() { + document.querySelectorAll(".task-date[data-ts]").forEach(function (el) { + const d = new Date(el.getAttribute("data-ts")) + if (!isNaN(d)) el.textContent = d.toLocaleString() + }) + } + + function init() { + localizeDates() + const container = document.getElementById("conversation") + const dataEl = document.getElementById("messages-data") + if (!container || !dataEl) return + + let messages = [] + try { + messages = JSON.parse(dataEl.textContent || "[]") + } catch (e) { + container.innerHTML = '
Could not load this conversation.
' + return + } + + container.innerHTML = "" + const convo = mountConversation(container) + convo.renderAll(messages) + + // Hand the live controller (if loaded) the same conversation instance so + // relayed events append to the history already on screen. + window.__tumbleConversation = convo + if (typeof window.TumbleLiveInit === "function") { + try { + window.TumbleLiveInit(convo) + } catch (e) { + /* live is best-effort */ + } + } + } + + if (document.readyState === "loading") { + document.addEventListener("DOMContentLoaded", init) + } else { + init() + } +})() diff --git a/self-hosted-cloudapi/src/web/static/vendor/marked.min.js b/self-hosted-cloudapi/src/web/static/vendor/marked.min.js new file mode 100644 index 0000000000..6312d5a9f9 --- /dev/null +++ b/self-hosted-cloudapi/src/web/static/vendor/marked.min.js @@ -0,0 +1,1592 @@ +/** + * marked v12.0.2 - a markdown parser + * Copyright (c) 2011-2024, Christopher Jeffrey. (MIT Licensed) + * https://github.com/markedjs/marked + */ +!(function (e, t) { + "object" == typeof exports && "undefined" != typeof module + ? t(exports) + : "function" == typeof define && define.amd + ? define(["exports"], t) + : t(((e = "undefined" != typeof globalThis ? globalThis : e || self).marked = {})) +})(this, function (e) { + "use strict" + function t() { + return { + async: !1, + breaks: !1, + extensions: null, + gfm: !0, + hooks: null, + pedantic: !1, + renderer: null, + silent: !1, + tokenizer: null, + walkTokens: null, + } + } + function n(t) { + e.defaults = t + } + e.defaults = { + async: !1, + breaks: !1, + extensions: null, + gfm: !0, + hooks: null, + pedantic: !1, + renderer: null, + silent: !1, + tokenizer: null, + walkTokens: null, + } + const s = /[&<>"']/, + r = new RegExp(s.source, "g"), + i = /[<>"']|&(?!(#\d{1,7}|#[Xx][a-fA-F0-9]{1,6}|\w+);)/, + l = new RegExp(i.source, "g"), + o = { "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }, + a = (e) => o[e] + function c(e, t) { + if (t) { + if (s.test(e)) return e.replace(r, a) + } else if (i.test(e)) return e.replace(l, a) + return e + } + const h = /&(#(?:\d+)|(?:#x[0-9A-Fa-f]+)|(?:\w+));?/gi + function p(e) { + return e.replace(h, (e, t) => + "colon" === (t = t.toLowerCase()) + ? ":" + : "#" === t.charAt(0) + ? "x" === t.charAt(1) + ? String.fromCharCode(parseInt(t.substring(2), 16)) + : String.fromCharCode(+t.substring(1)) + : "", + ) + } + const u = /(^|[^\[])\^/g + function k(e, t) { + let n = "string" == typeof e ? e : e.source + t = t || "" + const s = { + replace: (e, t) => { + let r = "string" == typeof t ? t : t.source + return (r = r.replace(u, "$1")), (n = n.replace(e, r)), s + }, + getRegex: () => new RegExp(n, t), + } + return s + } + function g(e) { + try { + e = encodeURI(e).replace(/%25/g, "%") + } catch (e) { + return null + } + return e + } + const f = { exec: () => null } + function d(e, t) { + const n = e + .replace(/\|/g, (e, t, n) => { + let s = !1, + r = t + for (; --r >= 0 && "\\" === n[r]; ) s = !s + return s ? "|" : " |" + }) + .split(/ \|/) + let s = 0 + if ((n[0].trim() || n.shift(), n.length > 0 && !n[n.length - 1].trim() && n.pop(), t)) + if (n.length > t) n.splice(t) + else for (; n.length < t; ) n.push("") + for (; s < n.length; s++) n[s] = n[s].trim().replace(/\\\|/g, "|") + return n + } + function x(e, t, n) { + const s = e.length + if (0 === s) return "" + let r = 0 + for (; r < s; ) { + const i = e.charAt(s - r - 1) + if (i !== t || n) { + if (i === t || !n) break + r++ + } else r++ + } + return e.slice(0, s - r) + } + function b(e, t, n, s) { + const r = t.href, + i = t.title ? c(t.title) : null, + l = e[1].replace(/\\([\[\]])/g, "$1") + if ("!" !== e[0].charAt(0)) { + s.state.inLink = !0 + const e = { type: "link", raw: n, href: r, title: i, text: l, tokens: s.inlineTokens(l) } + return (s.state.inLink = !1), e + } + return { type: "image", raw: n, href: r, title: i, text: c(l) } + } + class w { + options + rules + lexer + constructor(t) { + this.options = t || e.defaults + } + space(e) { + const t = this.rules.block.newline.exec(e) + if (t && t[0].length > 0) return { type: "space", raw: t[0] } + } + code(e) { + const t = this.rules.block.code.exec(e) + if (t) { + const e = t[0].replace(/^ {1,4}/gm, "") + return { + type: "code", + raw: t[0], + codeBlockStyle: "indented", + text: this.options.pedantic ? e : x(e, "\n"), + } + } + } + fences(e) { + const t = this.rules.block.fences.exec(e) + if (t) { + const e = t[0], + n = (function (e, t) { + const n = e.match(/^(\s+)(?:```)/) + if (null === n) return t + const s = n[1] + return t + .split("\n") + .map((e) => { + const t = e.match(/^\s+/) + if (null === t) return e + const [n] = t + return n.length >= s.length ? e.slice(s.length) : e + }) + .join("\n") + })(e, t[3] || "") + return { + type: "code", + raw: e, + lang: t[2] ? t[2].trim().replace(this.rules.inline.anyPunctuation, "$1") : t[2], + text: n, + } + } + } + heading(e) { + const t = this.rules.block.heading.exec(e) + if (t) { + let e = t[2].trim() + if (/#$/.test(e)) { + const t = x(e, "#") + this.options.pedantic ? (e = t.trim()) : (t && !/ $/.test(t)) || (e = t.trim()) + } + return { type: "heading", raw: t[0], depth: t[1].length, text: e, tokens: this.lexer.inline(e) } + } + } + hr(e) { + const t = this.rules.block.hr.exec(e) + if (t) return { type: "hr", raw: t[0] } + } + blockquote(e) { + const t = this.rules.block.blockquote.exec(e) + if (t) { + let e = t[0].replace(/\n {0,3}((?:=+|-+) *)(?=\n|$)/g, "\n $1") + e = x(e.replace(/^ *>[ \t]?/gm, ""), "\n") + const n = this.lexer.state.top + this.lexer.state.top = !0 + const s = this.lexer.blockTokens(e) + return (this.lexer.state.top = n), { type: "blockquote", raw: t[0], tokens: s, text: e } + } + } + list(e) { + let t = this.rules.block.list.exec(e) + if (t) { + let n = t[1].trim() + const s = n.length > 1, + r = { type: "list", raw: "", ordered: s, start: s ? +n.slice(0, -1) : "", loose: !1, items: [] } + ;(n = s ? `\\d{1,9}\\${n.slice(-1)}` : `\\${n}`), this.options.pedantic && (n = s ? n : "[*+-]") + const i = new RegExp(`^( {0,3}${n})((?:[\t ][^\\n]*)?(?:\\n|$))`) + let l = "", + o = "", + a = !1 + for (; e; ) { + let n = !1 + if (!(t = i.exec(e))) break + if (this.rules.block.hr.test(e)) break + ;(l = t[0]), (e = e.substring(l.length)) + let s = t[2].split("\n", 1)[0].replace(/^\t+/, (e) => " ".repeat(3 * e.length)), + c = e.split("\n", 1)[0], + h = 0 + this.options.pedantic + ? ((h = 2), (o = s.trimStart())) + : ((h = t[2].search(/[^ ]/)), (h = h > 4 ? 1 : h), (o = s.slice(h)), (h += t[1].length)) + let p = !1 + if ((!s && /^ *$/.test(c) && ((l += c + "\n"), (e = e.substring(c.length + 1)), (n = !0)), !n)) { + const t = new RegExp( + `^ {0,${Math.min(3, h - 1)}}(?:[*+-]|\\d{1,9}[.)])((?:[ \t][^\\n]*)?(?:\\n|$))`, + ), + n = new RegExp( + `^ {0,${Math.min(3, h - 1)}}((?:- *){3,}|(?:_ *){3,}|(?:\\* *){3,})(?:\\n+|$)`, + ), + r = new RegExp(`^ {0,${Math.min(3, h - 1)}}(?:\`\`\`|~~~)`), + i = new RegExp(`^ {0,${Math.min(3, h - 1)}}#`) + for (; e; ) { + const a = e.split("\n", 1)[0] + if ( + ((c = a), + this.options.pedantic && (c = c.replace(/^ {1,4}(?=( {4})*[^ ])/g, " ")), + r.test(c)) + ) + break + if (i.test(c)) break + if (t.test(c)) break + if (n.test(e)) break + if (c.search(/[^ ]/) >= h || !c.trim()) o += "\n" + c.slice(h) + else { + if (p) break + if (s.search(/[^ ]/) >= 4) break + if (r.test(s)) break + if (i.test(s)) break + if (n.test(s)) break + o += "\n" + c + } + p || c.trim() || (p = !0), + (l += a + "\n"), + (e = e.substring(a.length + 1)), + (s = c.slice(h)) + } + } + r.loose || (a ? (r.loose = !0) : /\n *\n *$/.test(l) && (a = !0)) + let u, + k = null + this.options.gfm && + ((k = /^\[[ xX]\] /.exec(o)), + k && ((u = "[ ] " !== k[0]), (o = o.replace(/^\[[ xX]\] +/, "")))), + r.items.push({ + type: "list_item", + raw: l, + task: !!k, + checked: u, + loose: !1, + text: o, + tokens: [], + }), + (r.raw += l) + } + ;(r.items[r.items.length - 1].raw = l.trimEnd()), + (r.items[r.items.length - 1].text = o.trimEnd()), + (r.raw = r.raw.trimEnd()) + for (let e = 0; e < r.items.length; e++) + if ( + ((this.lexer.state.top = !1), + (r.items[e].tokens = this.lexer.blockTokens(r.items[e].text, [])), + !r.loose) + ) { + const t = r.items[e].tokens.filter((e) => "space" === e.type), + n = t.length > 0 && t.some((e) => /\n.*\n/.test(e.raw)) + r.loose = n + } + if (r.loose) for (let e = 0; e < r.items.length; e++) r.items[e].loose = !0 + return r + } + } + html(e) { + const t = this.rules.block.html.exec(e) + if (t) { + return { + type: "html", + block: !0, + raw: t[0], + pre: "pre" === t[1] || "script" === t[1] || "style" === t[1], + text: t[0], + } + } + } + def(e) { + const t = this.rules.block.def.exec(e) + if (t) { + const e = t[1].toLowerCase().replace(/\s+/g, " "), + n = t[2] ? t[2].replace(/^<(.*)>$/, "$1").replace(this.rules.inline.anyPunctuation, "$1") : "", + s = t[3] ? t[3].substring(1, t[3].length - 1).replace(this.rules.inline.anyPunctuation, "$1") : t[3] + return { type: "def", tag: e, raw: t[0], href: n, title: s } + } + } + table(e) { + const t = this.rules.block.table.exec(e) + if (!t) return + if (!/[:|]/.test(t[2])) return + const n = d(t[1]), + s = t[2].replace(/^\||\| *$/g, "").split("|"), + r = t[3] && t[3].trim() ? t[3].replace(/\n[ \t]*$/, "").split("\n") : [], + i = { type: "table", raw: t[0], header: [], align: [], rows: [] } + if (n.length === s.length) { + for (const e of s) + /^ *-+: *$/.test(e) + ? i.align.push("right") + : /^ *:-+: *$/.test(e) + ? i.align.push("center") + : /^ *:-+ *$/.test(e) + ? i.align.push("left") + : i.align.push(null) + for (const e of n) i.header.push({ text: e, tokens: this.lexer.inline(e) }) + for (const e of r) + i.rows.push(d(e, i.header.length).map((e) => ({ text: e, tokens: this.lexer.inline(e) }))) + return i + } + } + lheading(e) { + const t = this.rules.block.lheading.exec(e) + if (t) + return { + type: "heading", + raw: t[0], + depth: "=" === t[2].charAt(0) ? 1 : 2, + text: t[1], + tokens: this.lexer.inline(t[1]), + } + } + paragraph(e) { + const t = this.rules.block.paragraph.exec(e) + if (t) { + const e = "\n" === t[1].charAt(t[1].length - 1) ? t[1].slice(0, -1) : t[1] + return { type: "paragraph", raw: t[0], text: e, tokens: this.lexer.inline(e) } + } + } + text(e) { + const t = this.rules.block.text.exec(e) + if (t) return { type: "text", raw: t[0], text: t[0], tokens: this.lexer.inline(t[0]) } + } + escape(e) { + const t = this.rules.inline.escape.exec(e) + if (t) return { type: "escape", raw: t[0], text: c(t[1]) } + } + tag(e) { + const t = this.rules.inline.tag.exec(e) + if (t) + return ( + !this.lexer.state.inLink && /^/i.test(t[0]) && (this.lexer.state.inLink = !1), + !this.lexer.state.inRawBlock && /^<(pre|code|kbd|script)(\s|>)/i.test(t[0]) + ? (this.lexer.state.inRawBlock = !0) + : this.lexer.state.inRawBlock && + /^<\/(pre|code|kbd|script)(\s|>)/i.test(t[0]) && + (this.lexer.state.inRawBlock = !1), + { + type: "html", + raw: t[0], + inLink: this.lexer.state.inLink, + inRawBlock: this.lexer.state.inRawBlock, + block: !1, + text: t[0], + } + ) + } + link(e) { + const t = this.rules.inline.link.exec(e) + if (t) { + const e = t[2].trim() + if (!this.options.pedantic && /^$/.test(e)) return + const t = x(e.slice(0, -1), "\\") + if ((e.length - t.length) % 2 == 0) return + } else { + const e = (function (e, t) { + if (-1 === e.indexOf(t[1])) return -1 + let n = 0 + for (let s = 0; s < e.length; s++) + if ("\\" === e[s]) s++ + else if (e[s] === t[0]) n++ + else if (e[s] === t[1] && (n--, n < 0)) return s + return -1 + })(t[2], "()") + if (e > -1) { + const n = (0 === t[0].indexOf("!") ? 5 : 4) + t[1].length + e + ;(t[2] = t[2].substring(0, e)), (t[0] = t[0].substring(0, n).trim()), (t[3] = "") + } + } + let n = t[2], + s = "" + if (this.options.pedantic) { + const e = /^([^'"]*[^\s])\s+(['"])(.*)\2/.exec(n) + e && ((n = e[1]), (s = e[3])) + } else s = t[3] ? t[3].slice(1, -1) : "" + return ( + (n = n.trim()), + /^$/.test(e) ? n.slice(1) : n.slice(1, -1)), + b( + t, + { + href: n ? n.replace(this.rules.inline.anyPunctuation, "$1") : n, + title: s ? s.replace(this.rules.inline.anyPunctuation, "$1") : s, + }, + t[0], + this.lexer, + ) + ) + } + } + reflink(e, t) { + let n + if ((n = this.rules.inline.reflink.exec(e)) || (n = this.rules.inline.nolink.exec(e))) { + const e = t[(n[2] || n[1]).replace(/\s+/g, " ").toLowerCase()] + if (!e) { + const e = n[0].charAt(0) + return { type: "text", raw: e, text: e } + } + return b(n, e, n[0], this.lexer) + } + } + emStrong(e, t, n = "") { + let s = this.rules.inline.emStrongLDelim.exec(e) + if (!s) return + if (s[3] && n.match(/[\p{L}\p{N}]/u)) return + if (!(s[1] || s[2] || "") || !n || this.rules.inline.punctuation.exec(n)) { + const n = [...s[0]].length - 1 + let r, + i, + l = n, + o = 0 + const a = "*" === s[0][0] ? this.rules.inline.emStrongRDelimAst : this.rules.inline.emStrongRDelimUnd + for (a.lastIndex = 0, t = t.slice(-1 * e.length + n); null != (s = a.exec(t)); ) { + if (((r = s[1] || s[2] || s[3] || s[4] || s[5] || s[6]), !r)) continue + if (((i = [...r].length), s[3] || s[4])) { + l += i + continue + } + if ((s[5] || s[6]) && n % 3 && !((n + i) % 3)) { + o += i + continue + } + if (((l -= i), l > 0)) continue + i = Math.min(i, i + l + o) + const t = [...s[0]][0].length, + a = e.slice(0, n + s.index + t + i) + if (Math.min(n, i) % 2) { + const e = a.slice(1, -1) + return { type: "em", raw: a, text: e, tokens: this.lexer.inlineTokens(e) } + } + const c = a.slice(2, -2) + return { type: "strong", raw: a, text: c, tokens: this.lexer.inlineTokens(c) } + } + } + } + codespan(e) { + const t = this.rules.inline.code.exec(e) + if (t) { + let e = t[2].replace(/\n/g, " ") + const n = /[^ ]/.test(e), + s = /^ /.test(e) && / $/.test(e) + return ( + n && s && (e = e.substring(1, e.length - 1)), + (e = c(e, !0)), + { type: "codespan", raw: t[0], text: e } + ) + } + } + br(e) { + const t = this.rules.inline.br.exec(e) + if (t) return { type: "br", raw: t[0] } + } + del(e) { + const t = this.rules.inline.del.exec(e) + if (t) return { type: "del", raw: t[0], text: t[2], tokens: this.lexer.inlineTokens(t[2]) } + } + autolink(e) { + const t = this.rules.inline.autolink.exec(e) + if (t) { + let e, n + return ( + "@" === t[2] ? ((e = c(t[1])), (n = "mailto:" + e)) : ((e = c(t[1])), (n = e)), + { type: "link", raw: t[0], text: e, href: n, tokens: [{ type: "text", raw: e, text: e }] } + ) + } + } + url(e) { + let t + if ((t = this.rules.inline.url.exec(e))) { + let e, n + if ("@" === t[2]) (e = c(t[0])), (n = "mailto:" + e) + else { + let s + do { + ;(s = t[0]), (t[0] = this.rules.inline._backpedal.exec(t[0])?.[0] ?? "") + } while (s !== t[0]) + ;(e = c(t[0])), (n = "www." === t[1] ? "http://" + t[0] : t[0]) + } + return { type: "link", raw: t[0], text: e, href: n, tokens: [{ type: "text", raw: e, text: e }] } + } + } + inlineText(e) { + const t = this.rules.inline.text.exec(e) + if (t) { + let e + return (e = this.lexer.state.inRawBlock ? t[0] : c(t[0])), { type: "text", raw: t[0], text: e } + } + } + } + const m = /^ {0,3}((?:-[\t ]*){3,}|(?:_[ \t]*){3,}|(?:\*[ \t]*){3,})(?:\n+|$)/, + y = /(?:[*+-]|\d{1,9}[.)])/, + $ = k( + /^(?!bull |blockCode|fences|blockquote|heading|html)((?:.|\n(?!\s*?\n|bull |blockCode|fences|blockquote|heading|html))+?)\n {0,3}(=+|-+) *(?:\n+|$)/, + ) + .replace(/bull/g, y) + .replace(/blockCode/g, / {4}/) + .replace(/fences/g, / {0,3}(?:`{3,}|~{3,})/) + .replace(/blockquote/g, / {0,3}>/) + .replace(/heading/g, / {0,3}#{1,6}/) + .replace(/html/g, / {0,3}<[^\n>]+>\n/) + .getRegex(), + z = /^([^\n]+(?:\n(?!hr|heading|lheading|blockquote|fences|list|html|table| +\n)[^\n]+)*)/, + T = /(?!\s*\])(?:\\.|[^\[\]\\])+/, + R = k(/^ {0,3}\[(label)\]: *(?:\n *)?([^<\s][^\s]*|<.*?>)(?:(?: +(?:\n *)?| *\n *)(title))? *(?:\n+|$)/) + .replace("label", T) + .replace("title", /(?:"(?:\\"?|[^"\\])*"|'[^'\n]*(?:\n[^'\n]+)*\n?'|\([^()]*\))/) + .getRegex(), + _ = k(/^( {0,3}bull)([ \t][^\n]+?)?(?:\n|$)/) + .replace(/bull/g, y) + .getRegex(), + A = + "address|article|aside|base|basefont|blockquote|body|caption|center|col|colgroup|dd|details|dialog|dir|div|dl|dt|fieldset|figcaption|figure|footer|form|frame|frameset|h[1-6]|head|header|hr|html|iframe|legend|li|link|main|menu|menuitem|meta|nav|noframes|ol|optgroup|option|p|param|search|section|summary|table|tbody|td|tfoot|th|thead|title|tr|track|ul", + S = /|$))/, + I = k( + "^ {0,3}(?:<(script|pre|style|textarea)[\\s>][\\s\\S]*?(?:[^\\n]*\\n+|$)|comment[^\\n]*(\\n+|$)|<\\?[\\s\\S]*?(?:\\?>\\n*|$)|\\n*|$)|\\n*|$)|)[\\s\\S]*?(?:(?:\\n *)+\\n|$)|<(?!script|pre|style|textarea)([a-z][\\w-]*)(?:attribute)*? */?>(?=[ \\t]*(?:\\n|$))[\\s\\S]*?(?:(?:\\n *)+\\n|$)|(?=[ \\t]*(?:\\n|$))[\\s\\S]*?(?:(?:\\n *)+\\n|$))", + "i", + ) + .replace("comment", S) + .replace("tag", A) + .replace("attribute", / +[a-zA-Z:_][\w.:-]*(?: *= *"[^"\n]*"| *= *'[^'\n]*'| *= *[^\s"'=<>`]+)?/) + .getRegex(), + E = k(z) + .replace("hr", m) + .replace("heading", " {0,3}#{1,6}(?:\\s|$)") + .replace("|lheading", "") + .replace("|table", "") + .replace("blockquote", " {0,3}>") + .replace("fences", " {0,3}(?:`{3,}(?=[^`\\n]*\\n)|~{3,})[^\\n]*\\n") + .replace("list", " {0,3}(?:[*+-]|1[.)]) ") + .replace("html", ")|<(?:script|pre|style|textarea|!--)") + .replace("tag", A) + .getRegex(), + q = { + blockquote: k(/^( {0,3}> ?(paragraph|[^\n]*)(?:\n|$))+/) + .replace("paragraph", E) + .getRegex(), + code: /^( {4}[^\n]+(?:\n(?: *(?:\n|$))*)?)+/, + def: R, + fences: /^ {0,3}(`{3,}(?=[^`\n]*(?:\n|$))|~{3,})([^\n]*)(?:\n|$)(?:|([\s\S]*?)(?:\n|$))(?: {0,3}\1[~`]* *(?=\n|$)|$)/, + heading: /^ {0,3}(#{1,6})(?=\s|$)(.*)(?:\n+|$)/, + hr: m, + html: I, + lheading: $, + list: _, + newline: /^(?: *(?:\n|$))+/, + paragraph: E, + table: f, + text: /^[^\n]+/, + }, + Z = k( + "^ *([^\\n ].*)\\n {0,3}((?:\\| *)?:?-+:? *(?:\\| *:?-+:? *)*(?:\\| *)?)(?:\\n((?:(?! *\\n|hr|heading|blockquote|code|fences|list|html).*(?:\\n|$))*)\\n*|$)", + ) + .replace("hr", m) + .replace("heading", " {0,3}#{1,6}(?:\\s|$)") + .replace("blockquote", " {0,3}>") + .replace("code", " {4}[^\\n]") + .replace("fences", " {0,3}(?:`{3,}(?=[^`\\n]*\\n)|~{3,})[^\\n]*\\n") + .replace("list", " {0,3}(?:[*+-]|1[.)]) ") + .replace("html", ")|<(?:script|pre|style|textarea|!--)") + .replace("tag", A) + .getRegex(), + L = { + ...q, + table: Z, + paragraph: k(z) + .replace("hr", m) + .replace("heading", " {0,3}#{1,6}(?:\\s|$)") + .replace("|lheading", "") + .replace("table", Z) + .replace("blockquote", " {0,3}>") + .replace("fences", " {0,3}(?:`{3,}(?=[^`\\n]*\\n)|~{3,})[^\\n]*\\n") + .replace("list", " {0,3}(?:[*+-]|1[.)]) ") + .replace("html", ")|<(?:script|pre|style|textarea|!--)") + .replace("tag", A) + .getRegex(), + }, + P = { + ...q, + html: k( + "^ *(?:comment *(?:\\n|\\s*$)|<(tag)[\\s\\S]+? *(?:\\n{2,}|\\s*$)|\\s]*)*?/?> *(?:\\n{2,}|\\s*$))", + ) + .replace("comment", S) + .replace( + /tag/g, + "(?!(?:a|em|strong|small|s|cite|q|dfn|abbr|data|time|code|var|samp|kbd|sub|sup|i|b|u|mark|ruby|rt|rp|bdi|bdo|span|br|wbr|ins|del|img)\\b)\\w+(?!:|[^\\w\\s@]*@)\\b", + ) + .getRegex(), + def: /^ *\[([^\]]+)\]: *]+)>?(?: +(["(][^\n]+[")]))? *(?:\n+|$)/, + heading: /^(#{1,6})(.*)(?:\n+|$)/, + fences: f, + lheading: /^(.+?)\n {0,3}(=+|-+) *(?:\n+|$)/, + paragraph: k(z) + .replace("hr", m) + .replace("heading", " *#{1,6} *[^\n]") + .replace("lheading", $) + .replace("|table", "") + .replace("blockquote", " {0,3}>") + .replace("|fences", "") + .replace("|list", "") + .replace("|html", "") + .replace("|tag", "") + .getRegex(), + }, + Q = /^\\([!"#$%&'()*+,\-./:;<=>?@\[\]\\^_`{|}~])/, + v = /^( {2,}|\\)\n(?!\s*$)/, + B = "\\p{P}\\p{S}", + C = k(/^((?![*_])[\spunctuation])/, "u") + .replace(/punctuation/g, B) + .getRegex(), + M = k(/^(?:\*+(?:((?!\*)[punct])|[^\s*]))|^_+(?:((?!_)[punct])|([^\s_]))/, "u") + .replace(/punct/g, B) + .getRegex(), + O = k( + "^[^_*]*?__[^_*]*?\\*[^_*]*?(?=__)|[^*]+(?=[^*])|(?!\\*)[punct](\\*+)(?=[\\s]|$)|[^punct\\s](\\*+)(?!\\*)(?=[punct\\s]|$)|(?!\\*)[punct\\s](\\*+)(?=[^punct\\s])|[\\s](\\*+)(?!\\*)(?=[punct])|(?!\\*)[punct](\\*+)(?!\\*)(?=[punct])|[^punct\\s](\\*+)(?=[^punct\\s])", + "gu", + ) + .replace(/punct/g, B) + .getRegex(), + D = k( + "^[^_*]*?\\*\\*[^_*]*?_[^_*]*?(?=\\*\\*)|[^_]+(?=[^_])|(?!_)[punct](_+)(?=[\\s]|$)|[^punct\\s](_+)(?!_)(?=[punct\\s]|$)|(?!_)[punct\\s](_+)(?=[^punct\\s])|[\\s](_+)(?!_)(?=[punct])|(?!_)[punct](_+)(?!_)(?=[punct])", + "gu", + ) + .replace(/punct/g, B) + .getRegex(), + j = k(/\\([punct])/, "gu") + .replace(/punct/g, B) + .getRegex(), + H = k(/^<(scheme:[^\s\x00-\x1f<>]*|email)>/) + .replace("scheme", /[a-zA-Z][a-zA-Z0-9+.-]{1,31}/) + .replace( + "email", + /[a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+(@)[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)+(?![-_])/, + ) + .getRegex(), + U = k(S).replace("(?:--\x3e|$)", "--\x3e").getRegex(), + X = k( + "^comment|^|^<[a-zA-Z][\\w-]*(?:attribute)*?\\s*/?>|^<\\?[\\s\\S]*?\\?>|^|^", + ) + .replace("comment", U) + .replace("attribute", /\s+[a-zA-Z:_][\w.:-]*(?:\s*=\s*"[^"]*"|\s*=\s*'[^']*'|\s*=\s*[^\s"'=<>`]+)?/) + .getRegex(), + F = /(?:\[(?:\\.|[^\[\]\\])*\]|\\.|`[^`]*`|[^\[\]\\`])*?/, + N = k(/^!?\[(label)\]\(\s*(href)(?:\s+(title))?\s*\)/) + .replace("label", F) + .replace("href", /<(?:\\.|[^\n<>\\])+>|[^\s\x00-\x1f]*/) + .replace("title", /"(?:\\"?|[^"\\])*"|'(?:\\'?|[^'\\])*'|\((?:\\\)?|[^)\\])*\)/) + .getRegex(), + G = k(/^!?\[(label)\]\[(ref)\]/) + .replace("label", F) + .replace("ref", T) + .getRegex(), + J = k(/^!?\[(ref)\](?:\[\])?/) + .replace("ref", T) + .getRegex(), + K = { + _backpedal: f, + anyPunctuation: j, + autolink: H, + blockSkip: /\[[^[\]]*?\]\([^\(\)]*?\)|`[^`]*?`|<[^<>]*?>/g, + br: v, + code: /^(`+)([^`]|[^`][\s\S]*?[^`])\1(?!`)/, + del: f, + emStrongLDelim: M, + emStrongRDelimAst: O, + emStrongRDelimUnd: D, + escape: Q, + link: N, + nolink: J, + punctuation: C, + reflink: G, + reflinkSearch: k("reflink|nolink(?!\\()", "g").replace("reflink", G).replace("nolink", J).getRegex(), + tag: X, + text: /^(`+|[^`])(?:(?= {2,}\n)|[\s\S]*?(?:(?=[\\ t + " ".repeat(n.length)); + e; + + ) + if ( + !( + this.options.extensions && + this.options.extensions.block && + this.options.extensions.block.some( + (s) => + !!(n = s.call({ lexer: this }, e, t)) && + ((e = e.substring(n.raw.length)), t.push(n), !0), + ) + ) + ) + if ((n = this.tokenizer.space(e))) + (e = e.substring(n.raw.length)), + 1 === n.raw.length && t.length > 0 ? (t[t.length - 1].raw += "\n") : t.push(n) + else if ((n = this.tokenizer.code(e))) + (e = e.substring(n.raw.length)), + (s = t[t.length - 1]), + !s || ("paragraph" !== s.type && "text" !== s.type) + ? t.push(n) + : ((s.raw += "\n" + n.raw), + (s.text += "\n" + n.text), + (this.inlineQueue[this.inlineQueue.length - 1].src = s.text)) + else if ((n = this.tokenizer.fences(e))) (e = e.substring(n.raw.length)), t.push(n) + else if ((n = this.tokenizer.heading(e))) (e = e.substring(n.raw.length)), t.push(n) + else if ((n = this.tokenizer.hr(e))) (e = e.substring(n.raw.length)), t.push(n) + else if ((n = this.tokenizer.blockquote(e))) (e = e.substring(n.raw.length)), t.push(n) + else if ((n = this.tokenizer.list(e))) (e = e.substring(n.raw.length)), t.push(n) + else if ((n = this.tokenizer.html(e))) (e = e.substring(n.raw.length)), t.push(n) + else if ((n = this.tokenizer.def(e))) + (e = e.substring(n.raw.length)), + (s = t[t.length - 1]), + !s || ("paragraph" !== s.type && "text" !== s.type) + ? this.tokens.links[n.tag] || + (this.tokens.links[n.tag] = { href: n.href, title: n.title }) + : ((s.raw += "\n" + n.raw), + (s.text += "\n" + n.raw), + (this.inlineQueue[this.inlineQueue.length - 1].src = s.text)) + else if ((n = this.tokenizer.table(e))) (e = e.substring(n.raw.length)), t.push(n) + else if ((n = this.tokenizer.lheading(e))) (e = e.substring(n.raw.length)), t.push(n) + else { + if (((r = e), this.options.extensions && this.options.extensions.startBlock)) { + let t = 1 / 0 + const n = e.slice(1) + let s + this.options.extensions.startBlock.forEach((e) => { + ;(s = e.call({ lexer: this }, n)), + "number" == typeof s && s >= 0 && (t = Math.min(t, s)) + }), + t < 1 / 0 && t >= 0 && (r = e.substring(0, t + 1)) + } + if (this.state.top && (n = this.tokenizer.paragraph(r))) + (s = t[t.length - 1]), + i && "paragraph" === s.type + ? ((s.raw += "\n" + n.raw), + (s.text += "\n" + n.text), + this.inlineQueue.pop(), + (this.inlineQueue[this.inlineQueue.length - 1].src = s.text)) + : t.push(n), + (i = r.length !== e.length), + (e = e.substring(n.raw.length)) + else if ((n = this.tokenizer.text(e))) + (e = e.substring(n.raw.length)), + (s = t[t.length - 1]), + s && "text" === s.type + ? ((s.raw += "\n" + n.raw), + (s.text += "\n" + n.text), + this.inlineQueue.pop(), + (this.inlineQueue[this.inlineQueue.length - 1].src = s.text)) + : t.push(n) + else if (e) { + const t = "Infinite loop on byte: " + e.charCodeAt(0) + if (this.options.silent) { + console.error(t) + break + } + throw new Error(t) + } + } + return (this.state.top = !0), t + } + inline(e, t = []) { + return this.inlineQueue.push({ src: e, tokens: t }), t + } + inlineTokens(e, t = []) { + let n, + s, + r, + i, + l, + o, + a = e + if (this.tokens.links) { + const e = Object.keys(this.tokens.links) + if (e.length > 0) + for (; null != (i = this.tokenizer.rules.inline.reflinkSearch.exec(a)); ) + e.includes(i[0].slice(i[0].lastIndexOf("[") + 1, -1)) && + (a = + a.slice(0, i.index) + + "[" + + "a".repeat(i[0].length - 2) + + "]" + + a.slice(this.tokenizer.rules.inline.reflinkSearch.lastIndex)) + } + for (; null != (i = this.tokenizer.rules.inline.blockSkip.exec(a)); ) + a = + a.slice(0, i.index) + + "[" + + "a".repeat(i[0].length - 2) + + "]" + + a.slice(this.tokenizer.rules.inline.blockSkip.lastIndex) + for (; null != (i = this.tokenizer.rules.inline.anyPunctuation.exec(a)); ) + a = a.slice(0, i.index) + "++" + a.slice(this.tokenizer.rules.inline.anyPunctuation.lastIndex) + for (; e; ) + if ( + (l || (o = ""), + (l = !1), + !( + this.options.extensions && + this.options.extensions.inline && + this.options.extensions.inline.some( + (s) => + !!(n = s.call({ lexer: this }, e, t)) && + ((e = e.substring(n.raw.length)), t.push(n), !0), + ) + )) + ) + if ((n = this.tokenizer.escape(e))) (e = e.substring(n.raw.length)), t.push(n) + else if ((n = this.tokenizer.tag(e))) + (e = e.substring(n.raw.length)), + (s = t[t.length - 1]), + s && "text" === n.type && "text" === s.type + ? ((s.raw += n.raw), (s.text += n.text)) + : t.push(n) + else if ((n = this.tokenizer.link(e))) (e = e.substring(n.raw.length)), t.push(n) + else if ((n = this.tokenizer.reflink(e, this.tokens.links))) + (e = e.substring(n.raw.length)), + (s = t[t.length - 1]), + s && "text" === n.type && "text" === s.type + ? ((s.raw += n.raw), (s.text += n.text)) + : t.push(n) + else if ((n = this.tokenizer.emStrong(e, a, o))) (e = e.substring(n.raw.length)), t.push(n) + else if ((n = this.tokenizer.codespan(e))) (e = e.substring(n.raw.length)), t.push(n) + else if ((n = this.tokenizer.br(e))) (e = e.substring(n.raw.length)), t.push(n) + else if ((n = this.tokenizer.del(e))) (e = e.substring(n.raw.length)), t.push(n) + else if ((n = this.tokenizer.autolink(e))) (e = e.substring(n.raw.length)), t.push(n) + else if (this.state.inLink || !(n = this.tokenizer.url(e))) { + if (((r = e), this.options.extensions && this.options.extensions.startInline)) { + let t = 1 / 0 + const n = e.slice(1) + let s + this.options.extensions.startInline.forEach((e) => { + ;(s = e.call({ lexer: this }, n)), + "number" == typeof s && s >= 0 && (t = Math.min(t, s)) + }), + t < 1 / 0 && t >= 0 && (r = e.substring(0, t + 1)) + } + if ((n = this.tokenizer.inlineText(r))) + (e = e.substring(n.raw.length)), + "_" !== n.raw.slice(-1) && (o = n.raw.slice(-1)), + (l = !0), + (s = t[t.length - 1]), + s && "text" === s.type ? ((s.raw += n.raw), (s.text += n.text)) : t.push(n) + else if (e) { + const t = "Infinite loop on byte: " + e.charCodeAt(0) + if (this.options.silent) { + console.error(t) + break + } + throw new Error(t) + } + } else (e = e.substring(n.raw.length)), t.push(n) + return t + } + } + class se { + options + constructor(t) { + this.options = t || e.defaults + } + code(e, t, n) { + const s = (t || "").match(/^\S*/)?.[0] + return ( + (e = e.replace(/\n$/, "") + "\n"), + s + ? '
' + (n ? e : c(e, !0)) + "
\n" + : "
" + (n ? e : c(e, !0)) + "
\n" + ) + } + blockquote(e) { + return `
\n${e}
\n` + } + html(e, t) { + return e + } + heading(e, t, n) { + return `${e}\n` + } + hr() { + return "
\n" + } + list(e, t, n) { + const s = t ? "ol" : "ul" + return "<" + s + (t && 1 !== n ? ' start="' + n + '"' : "") + ">\n" + e + "\n" + } + listitem(e, t, n) { + return `
  • ${e}
  • \n` + } + checkbox(e) { + return "' + } + paragraph(e) { + return `

    ${e}

    \n` + } + table(e, t) { + return t && (t = `${t}`), "\n\n" + e + "\n" + t + "
    \n" + } + tablerow(e) { + return `\n${e}\n` + } + tablecell(e, t) { + const n = t.header ? "th" : "td" + return (t.align ? `<${n} align="${t.align}">` : `<${n}>`) + e + `\n` + } + strong(e) { + return `${e}` + } + em(e) { + return `${e}` + } + codespan(e) { + return `${e}` + } + br() { + return "
    " + } + del(e) { + return `${e}` + } + link(e, t, n) { + const s = g(e) + if (null === s) return n + let r = '
    "), r + } + image(e, t, n) { + const s = g(e) + if (null === s) return n + let r = `${n} 0 && "paragraph" === n.tokens[0].type + ? ((n.tokens[0].text = e + " " + n.tokens[0].text), + n.tokens[0].tokens && + n.tokens[0].tokens.length > 0 && + "text" === n.tokens[0].tokens[0].type && + (n.tokens[0].tokens[0].text = e + " " + n.tokens[0].tokens[0].text)) + : n.tokens.unshift({ type: "text", text: e + " " }) + : (o += e + " ") + } + ;(o += this.parse(n.tokens, i)), (l += this.renderer.listitem(o, r, !!s)) + } + n += this.renderer.list(l, t, s) + continue + } + case "html": { + const e = r + n += this.renderer.html(e.text, e.block) + continue + } + case "paragraph": { + const e = r + n += this.renderer.paragraph(this.parseInline(e.tokens)) + continue + } + case "text": { + let i = r, + l = i.tokens ? this.parseInline(i.tokens) : i.text + for (; s + 1 < e.length && "text" === e[s + 1].type; ) + (i = e[++s]), (l += "\n" + (i.tokens ? this.parseInline(i.tokens) : i.text)) + n += t ? this.renderer.paragraph(l) : l + continue + } + default: { + const e = 'Token with "' + r.type + '" type was not found.' + if (this.options.silent) return console.error(e), "" + throw new Error(e) + } + } + } + return n + } + parseInline(e, t) { + t = t || this.renderer + let n = "" + for (let s = 0; s < e.length; s++) { + const r = e[s] + if ( + this.options.extensions && + this.options.extensions.renderers && + this.options.extensions.renderers[r.type] + ) { + const e = this.options.extensions.renderers[r.type].call({ parser: this }, r) + if ( + !1 !== e || + !["escape", "html", "link", "image", "strong", "em", "codespan", "br", "del", "text"].includes( + r.type, + ) + ) { + n += e || "" + continue + } + } + switch (r.type) { + case "escape": { + const e = r + n += t.text(e.text) + break + } + case "html": { + const e = r + n += t.html(e.text) + break + } + case "link": { + const e = r + n += t.link(e.href, e.title, this.parseInline(e.tokens, t)) + break + } + case "image": { + const e = r + n += t.image(e.href, e.title, e.text) + break + } + case "strong": { + const e = r + n += t.strong(this.parseInline(e.tokens, t)) + break + } + case "em": { + const e = r + n += t.em(this.parseInline(e.tokens, t)) + break + } + case "codespan": { + const e = r + n += t.codespan(e.text) + break + } + case "br": + n += t.br() + break + case "del": { + const e = r + n += t.del(this.parseInline(e.tokens, t)) + break + } + case "text": { + const e = r + n += t.text(e.text) + break + } + default: { + const e = 'Token with "' + r.type + '" type was not found.' + if (this.options.silent) return console.error(e), "" + throw new Error(e) + } + } + } + return n + } + } + class le { + options + constructor(t) { + this.options = t || e.defaults + } + static passThroughHooks = new Set(["preprocess", "postprocess", "processAllTokens"]) + preprocess(e) { + return e + } + postprocess(e) { + return e + } + processAllTokens(e) { + return e + } + } + class oe { + defaults = { + async: !1, + breaks: !1, + extensions: null, + gfm: !0, + hooks: null, + pedantic: !1, + renderer: null, + silent: !1, + tokenizer: null, + walkTokens: null, + } + options = this.setOptions + parse = this.#e(ne.lex, ie.parse) + parseInline = this.#e(ne.lexInline, ie.parseInline) + Parser = ie + Renderer = se + TextRenderer = re + Lexer = ne + Tokenizer = w + Hooks = le + constructor(...e) { + this.use(...e) + } + walkTokens(e, t) { + let n = [] + for (const s of e) + switch (((n = n.concat(t.call(this, s))), s.type)) { + case "table": { + const e = s + for (const s of e.header) n = n.concat(this.walkTokens(s.tokens, t)) + for (const s of e.rows) for (const e of s) n = n.concat(this.walkTokens(e.tokens, t)) + break + } + case "list": { + const e = s + n = n.concat(this.walkTokens(e.items, t)) + break + } + default: { + const e = s + this.defaults.extensions?.childTokens?.[e.type] + ? this.defaults.extensions.childTokens[e.type].forEach((s) => { + const r = e[s].flat(1 / 0) + n = n.concat(this.walkTokens(r, t)) + }) + : e.tokens && (n = n.concat(this.walkTokens(e.tokens, t))) + } + } + return n + } + use(...e) { + const t = this.defaults.extensions || { renderers: {}, childTokens: {} } + return ( + e.forEach((e) => { + const n = { ...e } + if ( + ((n.async = this.defaults.async || n.async || !1), + e.extensions && + (e.extensions.forEach((e) => { + if (!e.name) throw new Error("extension name required") + if ("renderer" in e) { + const n = t.renderers[e.name] + t.renderers[e.name] = n + ? function (...t) { + let s = e.renderer.apply(this, t) + return !1 === s && (s = n.apply(this, t)), s + } + : e.renderer + } + if ("tokenizer" in e) { + if (!e.level || ("block" !== e.level && "inline" !== e.level)) + throw new Error("extension level must be 'block' or 'inline'") + const n = t[e.level] + n ? n.unshift(e.tokenizer) : (t[e.level] = [e.tokenizer]), + e.start && + ("block" === e.level + ? t.startBlock + ? t.startBlock.push(e.start) + : (t.startBlock = [e.start]) + : "inline" === e.level && + (t.startInline + ? t.startInline.push(e.start) + : (t.startInline = [e.start]))) + } + "childTokens" in e && e.childTokens && (t.childTokens[e.name] = e.childTokens) + }), + (n.extensions = t)), + e.renderer) + ) { + const t = this.defaults.renderer || new se(this.defaults) + for (const n in e.renderer) { + if (!(n in t)) throw new Error(`renderer '${n}' does not exist`) + if ("options" === n) continue + const s = n, + r = e.renderer[s], + i = t[s] + t[s] = (...e) => { + let n = r.apply(t, e) + return !1 === n && (n = i.apply(t, e)), n || "" + } + } + n.renderer = t + } + if (e.tokenizer) { + const t = this.defaults.tokenizer || new w(this.defaults) + for (const n in e.tokenizer) { + if (!(n in t)) throw new Error(`tokenizer '${n}' does not exist`) + if (["options", "rules", "lexer"].includes(n)) continue + const s = n, + r = e.tokenizer[s], + i = t[s] + t[s] = (...e) => { + let n = r.apply(t, e) + return !1 === n && (n = i.apply(t, e)), n + } + } + n.tokenizer = t + } + if (e.hooks) { + const t = this.defaults.hooks || new le() + for (const n in e.hooks) { + if (!(n in t)) throw new Error(`hook '${n}' does not exist`) + if ("options" === n) continue + const s = n, + r = e.hooks[s], + i = t[s] + le.passThroughHooks.has(n) + ? (t[s] = (e) => { + if (this.defaults.async) + return Promise.resolve(r.call(t, e)).then((e) => i.call(t, e)) + const n = r.call(t, e) + return i.call(t, n) + }) + : (t[s] = (...e) => { + let n = r.apply(t, e) + return !1 === n && (n = i.apply(t, e)), n + }) + } + n.hooks = t + } + if (e.walkTokens) { + const t = this.defaults.walkTokens, + s = e.walkTokens + n.walkTokens = function (e) { + let n = [] + return n.push(s.call(this, e)), t && (n = n.concat(t.call(this, e))), n + } + } + this.defaults = { ...this.defaults, ...n } + }), + this + ) + } + setOptions(e) { + return (this.defaults = { ...this.defaults, ...e }), this + } + lexer(e, t) { + return ne.lex(e, t ?? this.defaults) + } + parser(e, t) { + return ie.parse(e, t ?? this.defaults) + } + #e(e, t) { + return (n, s) => { + const r = { ...s }, + i = { ...this.defaults, ...r } + !0 === this.defaults.async && + !1 === r.async && + (i.silent || + console.warn( + "marked(): The async option was set to true by an extension. The async: false option sent to parse will be ignored.", + ), + (i.async = !0)) + const l = this.#t(!!i.silent, !!i.async) + if (null == n) return l(new Error("marked(): input parameter is undefined or null")) + if ("string" != typeof n) + return l( + new Error( + "marked(): input parameter is of type " + + Object.prototype.toString.call(n) + + ", string expected", + ), + ) + if ((i.hooks && (i.hooks.options = i), i.async)) + return Promise.resolve(i.hooks ? i.hooks.preprocess(n) : n) + .then((t) => e(t, i)) + .then((e) => (i.hooks ? i.hooks.processAllTokens(e) : e)) + .then((e) => (i.walkTokens ? Promise.all(this.walkTokens(e, i.walkTokens)).then(() => e) : e)) + .then((e) => t(e, i)) + .then((e) => (i.hooks ? i.hooks.postprocess(e) : e)) + .catch(l) + try { + i.hooks && (n = i.hooks.preprocess(n)) + let s = e(n, i) + i.hooks && (s = i.hooks.processAllTokens(s)), i.walkTokens && this.walkTokens(s, i.walkTokens) + let r = t(s, i) + return i.hooks && (r = i.hooks.postprocess(r)), r + } catch (e) { + return l(e) + } + } + } + #t(e, t) { + return (n) => { + if (((n.message += "\nPlease report this to https://github.com/markedjs/marked."), e)) { + const e = "

    An error occurred:

    " + c(n.message + "", !0) + "
    " + return t ? Promise.resolve(e) : e + } + if (t) return Promise.reject(n) + throw n + } + } + } + const ae = new oe() + function ce(e, t) { + return ae.parse(e, t) + } + ;(ce.options = ce.setOptions = + function (e) { + return ae.setOptions(e), (ce.defaults = ae.defaults), n(ce.defaults), ce + }), + (ce.getDefaults = t), + (ce.defaults = e.defaults), + (ce.use = function (...e) { + return ae.use(...e), (ce.defaults = ae.defaults), n(ce.defaults), ce + }), + (ce.walkTokens = function (e, t) { + return ae.walkTokens(e, t) + }), + (ce.parseInline = ae.parseInline), + (ce.Parser = ie), + (ce.parser = ie.parse), + (ce.Renderer = se), + (ce.TextRenderer = re), + (ce.Lexer = ne), + (ce.lexer = ne.lex), + (ce.Tokenizer = w), + (ce.Hooks = le), + (ce.parse = ce) + const he = ce.options, + pe = ce.setOptions, + ue = ce.use, + ke = ce.walkTokens, + ge = ce.parseInline, + fe = ce, + de = ie.parse, + xe = ne.lex + ;(e.Hooks = le), + (e.Lexer = ne), + (e.Marked = oe), + (e.Parser = ie), + (e.Renderer = se), + (e.TextRenderer = re), + (e.Tokenizer = w), + (e.getDefaults = t), + (e.lexer = xe), + (e.marked = ce), + (e.options = he), + (e.parse = fe), + (e.parseInline = ge), + (e.parser = de), + (e.setOptions = pe), + (e.use = ue), + (e.walkTokens = ke) +}) diff --git a/self-hosted-cloudapi/src/web/static/vendor/purify.min.js b/self-hosted-cloudapi/src/web/static/vendor/purify.min.js new file mode 100644 index 0000000000..84577f8fa9 --- /dev/null +++ b/self-hosted-cloudapi/src/web/static/vendor/purify.min.js @@ -0,0 +1,1316 @@ +/*! @license DOMPurify 3.1.6 | (c) Cure53 and other contributors | Released under the Apache license 2.0 and Mozilla Public License 2.0 | github.com/cure53/DOMPurify/blob/3.1.6/LICENSE */ +!(function (e, t) { + "object" == typeof exports && "undefined" != typeof module + ? (module.exports = t()) + : "function" == typeof define && define.amd + ? define(t) + : ((e = "undefined" != typeof globalThis ? globalThis : e || self).DOMPurify = t()) +})(this, function () { + "use strict" + const { entries: e, setPrototypeOf: t, isFrozen: n, getPrototypeOf: o, getOwnPropertyDescriptor: r } = Object + let { freeze: i, seal: a, create: l } = Object, + { apply: c, construct: s } = "undefined" != typeof Reflect && Reflect + i || + (i = function (e) { + return e + }), + a || + (a = function (e) { + return e + }), + c || + (c = function (e, t, n) { + return e.apply(t, n) + }), + s || + (s = function (e, t) { + return new e(...t) + }) + const u = b(Array.prototype.forEach), + m = b(Array.prototype.pop), + p = b(Array.prototype.push), + f = b(String.prototype.toLowerCase), + d = b(String.prototype.toString), + h = b(String.prototype.match), + g = b(String.prototype.replace), + T = b(String.prototype.indexOf), + y = b(String.prototype.trim), + E = b(Object.prototype.hasOwnProperty), + _ = b(RegExp.prototype.test), + A = + ((N = TypeError), + function () { + for (var e = arguments.length, t = new Array(e), n = 0; n < e; n++) t[n] = arguments[n] + return s(N, t) + }) + var N + function b(e) { + return function (t) { + for (var n = arguments.length, o = new Array(n > 1 ? n - 1 : 0), r = 1; r < n; r++) o[r - 1] = arguments[r] + return c(e, t, o) + } + } + function S(e, o) { + let r = arguments.length > 2 && void 0 !== arguments[2] ? arguments[2] : f + t && t(e, null) + let i = o.length + for (; i--; ) { + let t = o[i] + if ("string" == typeof t) { + const e = r(t) + e !== t && (n(o) || (o[i] = e), (t = e)) + } + e[t] = !0 + } + return e + } + function R(e) { + for (let t = 0; t < e.length; t++) { + E(e, t) || (e[t] = null) + } + return e + } + function w(t) { + const n = l(null) + for (const [o, r] of e(t)) { + E(t, o) && + (Array.isArray(r) + ? (n[o] = R(r)) + : r && "object" == typeof r && r.constructor === Object + ? (n[o] = w(r)) + : (n[o] = r)) + } + return n + } + function C(e, t) { + for (; null !== e; ) { + const n = r(e, t) + if (n) { + if (n.get) return b(n.get) + if ("function" == typeof n.value) return b(n.value) + } + e = o(e) + } + return function () { + return null + } + } + const L = i([ + "a", + "abbr", + "acronym", + "address", + "area", + "article", + "aside", + "audio", + "b", + "bdi", + "bdo", + "big", + "blink", + "blockquote", + "body", + "br", + "button", + "canvas", + "caption", + "center", + "cite", + "code", + "col", + "colgroup", + "content", + "data", + "datalist", + "dd", + "decorator", + "del", + "details", + "dfn", + "dialog", + "dir", + "div", + "dl", + "dt", + "element", + "em", + "fieldset", + "figcaption", + "figure", + "font", + "footer", + "form", + "h1", + "h2", + "h3", + "h4", + "h5", + "h6", + "head", + "header", + "hgroup", + "hr", + "html", + "i", + "img", + "input", + "ins", + "kbd", + "label", + "legend", + "li", + "main", + "map", + "mark", + "marquee", + "menu", + "menuitem", + "meter", + "nav", + "nobr", + "ol", + "optgroup", + "option", + "output", + "p", + "picture", + "pre", + "progress", + "q", + "rp", + "rt", + "ruby", + "s", + "samp", + "section", + "select", + "shadow", + "small", + "source", + "spacer", + "span", + "strike", + "strong", + "style", + "sub", + "summary", + "sup", + "table", + "tbody", + "td", + "template", + "textarea", + "tfoot", + "th", + "thead", + "time", + "tr", + "track", + "tt", + "u", + "ul", + "var", + "video", + "wbr", + ]), + D = i([ + "svg", + "a", + "altglyph", + "altglyphdef", + "altglyphitem", + "animatecolor", + "animatemotion", + "animatetransform", + "circle", + "clippath", + "defs", + "desc", + "ellipse", + "filter", + "font", + "g", + "glyph", + "glyphref", + "hkern", + "image", + "line", + "lineargradient", + "marker", + "mask", + "metadata", + "mpath", + "path", + "pattern", + "polygon", + "polyline", + "radialgradient", + "rect", + "stop", + "style", + "switch", + "symbol", + "text", + "textpath", + "title", + "tref", + "tspan", + "view", + "vkern", + ]), + v = i([ + "feBlend", + "feColorMatrix", + "feComponentTransfer", + "feComposite", + "feConvolveMatrix", + "feDiffuseLighting", + "feDisplacementMap", + "feDistantLight", + "feDropShadow", + "feFlood", + "feFuncA", + "feFuncB", + "feFuncG", + "feFuncR", + "feGaussianBlur", + "feImage", + "feMerge", + "feMergeNode", + "feMorphology", + "feOffset", + "fePointLight", + "feSpecularLighting", + "feSpotLight", + "feTile", + "feTurbulence", + ]), + O = i([ + "animate", + "color-profile", + "cursor", + "discard", + "font-face", + "font-face-format", + "font-face-name", + "font-face-src", + "font-face-uri", + "foreignobject", + "hatch", + "hatchpath", + "mesh", + "meshgradient", + "meshpatch", + "meshrow", + "missing-glyph", + "script", + "set", + "solidcolor", + "unknown", + "use", + ]), + x = i([ + "math", + "menclose", + "merror", + "mfenced", + "mfrac", + "mglyph", + "mi", + "mlabeledtr", + "mmultiscripts", + "mn", + "mo", + "mover", + "mpadded", + "mphantom", + "mroot", + "mrow", + "ms", + "mspace", + "msqrt", + "mstyle", + "msub", + "msup", + "msubsup", + "mtable", + "mtd", + "mtext", + "mtr", + "munder", + "munderover", + "mprescripts", + ]), + k = i([ + "maction", + "maligngroup", + "malignmark", + "mlongdiv", + "mscarries", + "mscarry", + "msgroup", + "mstack", + "msline", + "msrow", + "semantics", + "annotation", + "annotation-xml", + "mprescripts", + "none", + ]), + M = i(["#text"]), + I = i([ + "accept", + "action", + "align", + "alt", + "autocapitalize", + "autocomplete", + "autopictureinpicture", + "autoplay", + "background", + "bgcolor", + "border", + "capture", + "cellpadding", + "cellspacing", + "checked", + "cite", + "class", + "clear", + "color", + "cols", + "colspan", + "controls", + "controlslist", + "coords", + "crossorigin", + "datetime", + "decoding", + "default", + "dir", + "disabled", + "disablepictureinpicture", + "disableremoteplayback", + "download", + "draggable", + "enctype", + "enterkeyhint", + "face", + "for", + "headers", + "height", + "hidden", + "high", + "href", + "hreflang", + "id", + "inputmode", + "integrity", + "ismap", + "kind", + "label", + "lang", + "list", + "loading", + "loop", + "low", + "max", + "maxlength", + "media", + "method", + "min", + "minlength", + "multiple", + "muted", + "name", + "nonce", + "noshade", + "novalidate", + "nowrap", + "open", + "optimum", + "pattern", + "placeholder", + "playsinline", + "popover", + "popovertarget", + "popovertargetaction", + "poster", + "preload", + "pubdate", + "radiogroup", + "readonly", + "rel", + "required", + "rev", + "reversed", + "role", + "rows", + "rowspan", + "spellcheck", + "scope", + "selected", + "shape", + "size", + "sizes", + "span", + "srclang", + "start", + "src", + "srcset", + "step", + "style", + "summary", + "tabindex", + "title", + "translate", + "type", + "usemap", + "valign", + "value", + "width", + "wrap", + "xmlns", + "slot", + ]), + U = i([ + "accent-height", + "accumulate", + "additive", + "alignment-baseline", + "ascent", + "attributename", + "attributetype", + "azimuth", + "basefrequency", + "baseline-shift", + "begin", + "bias", + "by", + "class", + "clip", + "clippathunits", + "clip-path", + "clip-rule", + "color", + "color-interpolation", + "color-interpolation-filters", + "color-profile", + "color-rendering", + "cx", + "cy", + "d", + "dx", + "dy", + "diffuseconstant", + "direction", + "display", + "divisor", + "dur", + "edgemode", + "elevation", + "end", + "fill", + "fill-opacity", + "fill-rule", + "filter", + "filterunits", + "flood-color", + "flood-opacity", + "font-family", + "font-size", + "font-size-adjust", + "font-stretch", + "font-style", + "font-variant", + "font-weight", + "fx", + "fy", + "g1", + "g2", + "glyph-name", + "glyphref", + "gradientunits", + "gradienttransform", + "height", + "href", + "id", + "image-rendering", + "in", + "in2", + "k", + "k1", + "k2", + "k3", + "k4", + "kerning", + "keypoints", + "keysplines", + "keytimes", + "lang", + "lengthadjust", + "letter-spacing", + "kernelmatrix", + "kernelunitlength", + "lighting-color", + "local", + "marker-end", + "marker-mid", + "marker-start", + "markerheight", + "markerunits", + "markerwidth", + "maskcontentunits", + "maskunits", + "max", + "mask", + "media", + "method", + "mode", + "min", + "name", + "numoctaves", + "offset", + "operator", + "opacity", + "order", + "orient", + "orientation", + "origin", + "overflow", + "paint-order", + "path", + "pathlength", + "patterncontentunits", + "patterntransform", + "patternunits", + "points", + "preservealpha", + "preserveaspectratio", + "primitiveunits", + "r", + "rx", + "ry", + "radius", + "refx", + "refy", + "repeatcount", + "repeatdur", + "restart", + "result", + "rotate", + "scale", + "seed", + "shape-rendering", + "specularconstant", + "specularexponent", + "spreadmethod", + "startoffset", + "stddeviation", + "stitchtiles", + "stop-color", + "stop-opacity", + "stroke-dasharray", + "stroke-dashoffset", + "stroke-linecap", + "stroke-linejoin", + "stroke-miterlimit", + "stroke-opacity", + "stroke", + "stroke-width", + "style", + "surfacescale", + "systemlanguage", + "tabindex", + "targetx", + "targety", + "transform", + "transform-origin", + "text-anchor", + "text-decoration", + "text-rendering", + "textlength", + "type", + "u1", + "u2", + "unicode", + "values", + "viewbox", + "visibility", + "version", + "vert-adv-y", + "vert-origin-x", + "vert-origin-y", + "width", + "word-spacing", + "wrap", + "writing-mode", + "xchannelselector", + "ychannelselector", + "x", + "x1", + "x2", + "xmlns", + "y", + "y1", + "y2", + "z", + "zoomandpan", + ]), + P = i([ + "accent", + "accentunder", + "align", + "bevelled", + "close", + "columnsalign", + "columnlines", + "columnspan", + "denomalign", + "depth", + "dir", + "display", + "displaystyle", + "encoding", + "fence", + "frame", + "height", + "href", + "id", + "largeop", + "length", + "linethickness", + "lspace", + "lquote", + "mathbackground", + "mathcolor", + "mathsize", + "mathvariant", + "maxsize", + "minsize", + "movablelimits", + "notation", + "numalign", + "open", + "rowalign", + "rowlines", + "rowspacing", + "rowspan", + "rspace", + "rquote", + "scriptlevel", + "scriptminsize", + "scriptsizemultiplier", + "selection", + "separator", + "separators", + "stretchy", + "subscriptshift", + "supscriptshift", + "symmetric", + "voffset", + "width", + "xmlns", + ]), + F = i(["xlink:href", "xml:id", "xlink:title", "xml:space", "xmlns:xlink"]), + H = a(/\{\{[\w\W]*|[\w\W]*\}\}/gm), + z = a(/<%[\w\W]*|[\w\W]*%>/gm), + B = a(/\${[\w\W]*}/gm), + W = a(/^data-[\-\w.\u00B7-\uFFFF]/), + G = a(/^aria-[\-\w]+$/), + Y = a(/^(?:(?:(?:f|ht)tps?|mailto|tel|callto|sms|cid|xmpp):|[^a-z]|[a-z+.\-]+(?:[^a-z+.\-:]|$))/i), + j = a(/^(?:\w+script|data):/i), + X = a(/[\u0000-\u0020\u00A0\u1680\u180E\u2000-\u2029\u205F\u3000]/g), + q = a(/^html$/i), + $ = a(/^[a-z][.\w]*(-[.\w]+)+$/i) + var K = Object.freeze({ + __proto__: null, + MUSTACHE_EXPR: H, + ERB_EXPR: z, + TMPLIT_EXPR: B, + DATA_ATTR: W, + ARIA_ATTR: G, + IS_ALLOWED_URI: Y, + IS_SCRIPT_OR_DATA: j, + ATTR_WHITESPACE: X, + DOCTYPE_NAME: q, + CUSTOM_ELEMENT: $, + }) + const V = 1, + Z = 3, + J = 7, + Q = 8, + ee = 9, + te = function () { + return "undefined" == typeof window ? null : window + } + var ne = (function t() { + let n = arguments.length > 0 && void 0 !== arguments[0] ? arguments[0] : te() + const o = (e) => t(e) + if (((o.version = "3.1.6"), (o.removed = []), !n || !n.document || n.document.nodeType !== ee)) + return (o.isSupported = !1), o + let { document: r } = n + const a = r, + c = a.currentScript, + { + DocumentFragment: s, + HTMLTemplateElement: N, + Node: b, + Element: R, + NodeFilter: H, + NamedNodeMap: z = n.NamedNodeMap || n.MozNamedAttrMap, + HTMLFormElement: B, + DOMParser: W, + trustedTypes: G, + } = n, + j = R.prototype, + X = C(j, "cloneNode"), + $ = C(j, "remove"), + ne = C(j, "nextSibling"), + oe = C(j, "childNodes"), + re = C(j, "parentNode") + if ("function" == typeof N) { + const e = r.createElement("template") + e.content && e.content.ownerDocument && (r = e.content.ownerDocument) + } + let ie, + ae = "" + const { implementation: le, createNodeIterator: ce, createDocumentFragment: se, getElementsByTagName: ue } = r, + { importNode: me } = a + let pe = {} + o.isSupported = "function" == typeof e && "function" == typeof re && le && void 0 !== le.createHTMLDocument + const { + MUSTACHE_EXPR: fe, + ERB_EXPR: de, + TMPLIT_EXPR: he, + DATA_ATTR: ge, + ARIA_ATTR: Te, + IS_SCRIPT_OR_DATA: ye, + ATTR_WHITESPACE: Ee, + CUSTOM_ELEMENT: _e, + } = K + let { IS_ALLOWED_URI: Ae } = K, + Ne = null + const be = S({}, [...L, ...D, ...v, ...x, ...M]) + let Se = null + const Re = S({}, [...I, ...U, ...P, ...F]) + let we = Object.seal( + l(null, { + tagNameCheck: { writable: !0, configurable: !1, enumerable: !0, value: null }, + attributeNameCheck: { writable: !0, configurable: !1, enumerable: !0, value: null }, + allowCustomizedBuiltInElements: { writable: !0, configurable: !1, enumerable: !0, value: !1 }, + }), + ), + Ce = null, + Le = null, + De = !0, + ve = !0, + Oe = !1, + xe = !0, + ke = !1, + Me = !0, + Ie = !1, + Ue = !1, + Pe = !1, + Fe = !1, + He = !1, + ze = !1, + Be = !0, + We = !1, + Ge = !0, + Ye = !1, + je = {}, + Xe = null + const qe = S({}, [ + "annotation-xml", + "audio", + "colgroup", + "desc", + "foreignobject", + "head", + "iframe", + "math", + "mi", + "mn", + "mo", + "ms", + "mtext", + "noembed", + "noframes", + "noscript", + "plaintext", + "script", + "style", + "svg", + "template", + "thead", + "title", + "video", + "xmp", + ]) + let $e = null + const Ke = S({}, ["audio", "video", "img", "source", "image", "track"]) + let Ve = null + const Ze = S({}, [ + "alt", + "class", + "for", + "id", + "label", + "name", + "pattern", + "placeholder", + "role", + "summary", + "title", + "value", + "style", + "xmlns", + ]), + Je = "http://www.w3.org/1998/Math/MathML", + Qe = "http://www.w3.org/2000/svg", + et = "http://www.w3.org/1999/xhtml" + let tt = et, + nt = !1, + ot = null + const rt = S({}, [Je, Qe, et], d) + let it = null + const at = ["application/xhtml+xml", "text/html"] + let lt = null, + ct = null + const st = r.createElement("form"), + ut = function (e) { + return e instanceof RegExp || e instanceof Function + }, + mt = function () { + let e = arguments.length > 0 && void 0 !== arguments[0] ? arguments[0] : {} + if (!ct || ct !== e) { + if ( + ((e && "object" == typeof e) || (e = {}), + (e = w(e)), + (it = -1 === at.indexOf(e.PARSER_MEDIA_TYPE) ? "text/html" : e.PARSER_MEDIA_TYPE), + (lt = "application/xhtml+xml" === it ? d : f), + (Ne = E(e, "ALLOWED_TAGS") ? S({}, e.ALLOWED_TAGS, lt) : be), + (Se = E(e, "ALLOWED_ATTR") ? S({}, e.ALLOWED_ATTR, lt) : Re), + (ot = E(e, "ALLOWED_NAMESPACES") ? S({}, e.ALLOWED_NAMESPACES, d) : rt), + (Ve = E(e, "ADD_URI_SAFE_ATTR") ? S(w(Ze), e.ADD_URI_SAFE_ATTR, lt) : Ze), + ($e = E(e, "ADD_DATA_URI_TAGS") ? S(w(Ke), e.ADD_DATA_URI_TAGS, lt) : Ke), + (Xe = E(e, "FORBID_CONTENTS") ? S({}, e.FORBID_CONTENTS, lt) : qe), + (Ce = E(e, "FORBID_TAGS") ? S({}, e.FORBID_TAGS, lt) : {}), + (Le = E(e, "FORBID_ATTR") ? S({}, e.FORBID_ATTR, lt) : {}), + (je = !!E(e, "USE_PROFILES") && e.USE_PROFILES), + (De = !1 !== e.ALLOW_ARIA_ATTR), + (ve = !1 !== e.ALLOW_DATA_ATTR), + (Oe = e.ALLOW_UNKNOWN_PROTOCOLS || !1), + (xe = !1 !== e.ALLOW_SELF_CLOSE_IN_ATTR), + (ke = e.SAFE_FOR_TEMPLATES || !1), + (Me = !1 !== e.SAFE_FOR_XML), + (Ie = e.WHOLE_DOCUMENT || !1), + (Fe = e.RETURN_DOM || !1), + (He = e.RETURN_DOM_FRAGMENT || !1), + (ze = e.RETURN_TRUSTED_TYPE || !1), + (Pe = e.FORCE_BODY || !1), + (Be = !1 !== e.SANITIZE_DOM), + (We = e.SANITIZE_NAMED_PROPS || !1), + (Ge = !1 !== e.KEEP_CONTENT), + (Ye = e.IN_PLACE || !1), + (Ae = e.ALLOWED_URI_REGEXP || Y), + (tt = e.NAMESPACE || et), + (we = e.CUSTOM_ELEMENT_HANDLING || {}), + e.CUSTOM_ELEMENT_HANDLING && + ut(e.CUSTOM_ELEMENT_HANDLING.tagNameCheck) && + (we.tagNameCheck = e.CUSTOM_ELEMENT_HANDLING.tagNameCheck), + e.CUSTOM_ELEMENT_HANDLING && + ut(e.CUSTOM_ELEMENT_HANDLING.attributeNameCheck) && + (we.attributeNameCheck = e.CUSTOM_ELEMENT_HANDLING.attributeNameCheck), + e.CUSTOM_ELEMENT_HANDLING && + "boolean" == typeof e.CUSTOM_ELEMENT_HANDLING.allowCustomizedBuiltInElements && + (we.allowCustomizedBuiltInElements = + e.CUSTOM_ELEMENT_HANDLING.allowCustomizedBuiltInElements), + ke && (ve = !1), + He && (Fe = !0), + je && + ((Ne = S({}, M)), + (Se = []), + !0 === je.html && (S(Ne, L), S(Se, I)), + !0 === je.svg && (S(Ne, D), S(Se, U), S(Se, F)), + !0 === je.svgFilters && (S(Ne, v), S(Se, U), S(Se, F)), + !0 === je.mathMl && (S(Ne, x), S(Se, P), S(Se, F))), + e.ADD_TAGS && (Ne === be && (Ne = w(Ne)), S(Ne, e.ADD_TAGS, lt)), + e.ADD_ATTR && (Se === Re && (Se = w(Se)), S(Se, e.ADD_ATTR, lt)), + e.ADD_URI_SAFE_ATTR && S(Ve, e.ADD_URI_SAFE_ATTR, lt), + e.FORBID_CONTENTS && (Xe === qe && (Xe = w(Xe)), S(Xe, e.FORBID_CONTENTS, lt)), + Ge && (Ne["#text"] = !0), + Ie && S(Ne, ["html", "head", "body"]), + Ne.table && (S(Ne, ["tbody"]), delete Ce.tbody), + e.TRUSTED_TYPES_POLICY) + ) { + if ("function" != typeof e.TRUSTED_TYPES_POLICY.createHTML) + throw A('TRUSTED_TYPES_POLICY configuration option must provide a "createHTML" hook.') + if ("function" != typeof e.TRUSTED_TYPES_POLICY.createScriptURL) + throw A('TRUSTED_TYPES_POLICY configuration option must provide a "createScriptURL" hook.') + ;(ie = e.TRUSTED_TYPES_POLICY), (ae = ie.createHTML("")) + } else + void 0 === ie && + (ie = (function (e, t) { + if ("object" != typeof e || "function" != typeof e.createPolicy) return null + let n = null + const o = "data-tt-policy-suffix" + t && t.hasAttribute(o) && (n = t.getAttribute(o)) + const r = "dompurify" + (n ? "#" + n : "") + try { + return e.createPolicy(r, { createHTML: (e) => e, createScriptURL: (e) => e }) + } catch (e) { + return console.warn("TrustedTypes policy " + r + " could not be created."), null + } + })(G, c)), + null !== ie && "string" == typeof ae && (ae = ie.createHTML("")) + i && i(e), (ct = e) + } + }, + pt = S({}, ["mi", "mo", "mn", "ms", "mtext"]), + ft = S({}, ["foreignobject", "annotation-xml"]), + dt = S({}, ["title", "style", "font", "a", "script"]), + ht = S({}, [...D, ...v, ...O]), + gt = S({}, [...x, ...k]), + Tt = function (e) { + p(o.removed, { element: e }) + try { + re(e).removeChild(e) + } catch (t) { + $(e) + } + }, + yt = function (e, t) { + try { + p(o.removed, { attribute: t.getAttributeNode(e), from: t }) + } catch (e) { + p(o.removed, { attribute: null, from: t }) + } + if ((t.removeAttribute(e), "is" === e && !Se[e])) + if (Fe || He) + try { + Tt(t) + } catch (e) {} + else + try { + t.setAttribute(e, "") + } catch (e) {} + }, + Et = function (e) { + let t = null, + n = null + if (Pe) e = "" + e + else { + const t = h(e, /^[\r\n\t ]+/) + n = t && t[0] + } + "application/xhtml+xml" === it && + tt === et && + (e = '' + e + "") + const o = ie ? ie.createHTML(e) : e + if (tt === et) + try { + t = new W().parseFromString(o, it) + } catch (e) {} + if (!t || !t.documentElement) { + t = le.createDocument(tt, "template", null) + try { + t.documentElement.innerHTML = nt ? ae : o + } catch (e) {} + } + const i = t.body || t.documentElement + return ( + e && n && i.insertBefore(r.createTextNode(n), i.childNodes[0] || null), + tt === et ? ue.call(t, Ie ? "html" : "body")[0] : Ie ? t.documentElement : i + ) + }, + _t = function (e) { + return ce.call( + e.ownerDocument || e, + e, + H.SHOW_ELEMENT | + H.SHOW_COMMENT | + H.SHOW_TEXT | + H.SHOW_PROCESSING_INSTRUCTION | + H.SHOW_CDATA_SECTION, + null, + ) + }, + At = function (e) { + return ( + e instanceof B && + ("string" != typeof e.nodeName || + "string" != typeof e.textContent || + "function" != typeof e.removeChild || + !(e.attributes instanceof z) || + "function" != typeof e.removeAttribute || + "function" != typeof e.setAttribute || + "string" != typeof e.namespaceURI || + "function" != typeof e.insertBefore || + "function" != typeof e.hasChildNodes) + ) + }, + Nt = function (e) { + return "function" == typeof b && e instanceof b + }, + bt = function (e, t, n) { + pe[e] && + u(pe[e], (e) => { + e.call(o, t, n, ct) + }) + }, + St = function (e) { + let t = null + if ((bt("beforeSanitizeElements", e, null), At(e))) return Tt(e), !0 + const n = lt(e.nodeName) + if ( + (bt("uponSanitizeElement", e, { tagName: n, allowedTags: Ne }), + e.hasChildNodes() && + !Nt(e.firstElementChild) && + _(/<[/\w]/g, e.innerHTML) && + _(/<[/\w]/g, e.textContent)) + ) + return Tt(e), !0 + if (e.nodeType === J) return Tt(e), !0 + if (Me && e.nodeType === Q && _(/<[/\w]/g, e.data)) return Tt(e), !0 + if (!Ne[n] || Ce[n]) { + if (!Ce[n] && wt(n)) { + if (we.tagNameCheck instanceof RegExp && _(we.tagNameCheck, n)) return !1 + if (we.tagNameCheck instanceof Function && we.tagNameCheck(n)) return !1 + } + if (Ge && !Xe[n]) { + const t = re(e) || e.parentNode, + n = oe(e) || e.childNodes + if (n && t) { + for (let o = n.length - 1; o >= 0; --o) { + const r = X(n[o], !0) + ;(r.__removalCount = (e.__removalCount || 0) + 1), t.insertBefore(r, ne(e)) + } + } + } + return Tt(e), !0 + } + return e instanceof R && + !(function (e) { + let t = re(e) + ;(t && t.tagName) || (t = { namespaceURI: tt, tagName: "template" }) + const n = f(e.tagName), + o = f(t.tagName) + return ( + !!ot[e.namespaceURI] && + (e.namespaceURI === Qe + ? t.namespaceURI === et + ? "svg" === n + : t.namespaceURI === Je + ? "svg" === n && ("annotation-xml" === o || pt[o]) + : Boolean(ht[n]) + : e.namespaceURI === Je + ? t.namespaceURI === et + ? "math" === n + : t.namespaceURI === Qe + ? "math" === n && ft[o] + : Boolean(gt[n]) + : e.namespaceURI === et + ? !(t.namespaceURI === Qe && !ft[o]) && + !(t.namespaceURI === Je && !pt[o]) && + !gt[n] && + (dt[n] || !ht[n]) + : !("application/xhtml+xml" !== it || !ot[e.namespaceURI])) + ) + })(e) + ? (Tt(e), !0) + : ("noscript" !== n && "noembed" !== n && "noframes" !== n) || + !_(/<\/no(script|embed|frames)/i, e.innerHTML) + ? (ke && + e.nodeType === Z && + ((t = e.textContent), + u([fe, de, he], (e) => { + t = g(t, e, " ") + }), + e.textContent !== t && (p(o.removed, { element: e.cloneNode() }), (e.textContent = t))), + bt("afterSanitizeElements", e, null), + !1) + : (Tt(e), !0) + }, + Rt = function (e, t, n) { + if (Be && ("id" === t || "name" === t) && (n in r || n in st)) return !1 + if (ve && !Le[t] && _(ge, t)); + else if (De && _(Te, t)); + else if (!Se[t] || Le[t]) { + if ( + !( + (wt(e) && + ((we.tagNameCheck instanceof RegExp && _(we.tagNameCheck, e)) || + (we.tagNameCheck instanceof Function && we.tagNameCheck(e))) && + ((we.attributeNameCheck instanceof RegExp && _(we.attributeNameCheck, t)) || + (we.attributeNameCheck instanceof Function && we.attributeNameCheck(t)))) || + ("is" === t && + we.allowCustomizedBuiltInElements && + ((we.tagNameCheck instanceof RegExp && _(we.tagNameCheck, n)) || + (we.tagNameCheck instanceof Function && we.tagNameCheck(n)))) + ) + ) + return !1 + } else if (Ve[t]); + else if (_(Ae, g(n, Ee, ""))); + else if ( + ("src" !== t && "xlink:href" !== t && "href" !== t) || + "script" === e || + 0 !== T(n, "data:") || + !$e[e] + ) { + if (Oe && !_(ye, g(n, Ee, ""))); + else if (n) return !1 + } else; + return !0 + }, + wt = function (e) { + return "annotation-xml" !== e && h(e, _e) + }, + Ct = function (e) { + bt("beforeSanitizeAttributes", e, null) + const { attributes: t } = e + if (!t) return + const n = { attrName: "", attrValue: "", keepAttr: !0, allowedAttributes: Se } + let r = t.length + for (; r--; ) { + const i = t[r], + { name: a, namespaceURI: l, value: c } = i, + s = lt(a) + let p = "value" === a ? c : y(c) + if ( + ((n.attrName = s), + (n.attrValue = p), + (n.keepAttr = !0), + (n.forceKeepAttr = void 0), + bt("uponSanitizeAttribute", e, n), + (p = n.attrValue), + Me && _(/((--!?|])>)|<\/(style|title)/i, p)) + ) { + yt(a, e) + continue + } + if (n.forceKeepAttr) continue + if ((yt(a, e), !n.keepAttr)) continue + if (!xe && _(/\/>/i, p)) { + yt(a, e) + continue + } + ke && + u([fe, de, he], (e) => { + p = g(p, e, " ") + }) + const f = lt(e.nodeName) + if (Rt(f, s, p)) { + if ( + (!We || ("id" !== s && "name" !== s) || (yt(a, e), (p = "user-content-" + p)), + ie && "object" == typeof G && "function" == typeof G.getAttributeType) + ) + if (l); + else + switch (G.getAttributeType(f, s)) { + case "TrustedHTML": + p = ie.createHTML(p) + break + case "TrustedScriptURL": + p = ie.createScriptURL(p) + } + try { + l ? e.setAttributeNS(l, a, p) : e.setAttribute(a, p), At(e) ? Tt(e) : m(o.removed) + } catch (e) {} + } + } + bt("afterSanitizeAttributes", e, null) + }, + Lt = function e(t) { + let n = null + const o = _t(t) + for (bt("beforeSanitizeShadowDOM", t, null); (n = o.nextNode()); ) + bt("uponSanitizeShadowNode", n, null), St(n) || (n.content instanceof s && e(n.content), Ct(n)) + bt("afterSanitizeShadowDOM", t, null) + } + return ( + (o.sanitize = function (e) { + let t = arguments.length > 1 && void 0 !== arguments[1] ? arguments[1] : {}, + n = null, + r = null, + i = null, + l = null + if (((nt = !e), nt && (e = "\x3c!--\x3e"), "string" != typeof e && !Nt(e))) { + if ("function" != typeof e.toString) throw A("toString is not a function") + if ("string" != typeof (e = e.toString())) throw A("dirty is not a string, aborting") + } + if (!o.isSupported) return e + if ((Ue || mt(t), (o.removed = []), "string" == typeof e && (Ye = !1), Ye)) { + if (e.nodeName) { + const t = lt(e.nodeName) + if (!Ne[t] || Ce[t]) throw A("root node is forbidden and cannot be sanitized in-place") + } + } else if (e instanceof b) + (n = Et("\x3c!----\x3e")), + (r = n.ownerDocument.importNode(e, !0)), + (r.nodeType === V && "BODY" === r.nodeName) || "HTML" === r.nodeName + ? (n = r) + : n.appendChild(r) + else { + if (!Fe && !ke && !Ie && -1 === e.indexOf("<")) return ie && ze ? ie.createHTML(e) : e + if (((n = Et(e)), !n)) return Fe ? null : ze ? ae : "" + } + n && Pe && Tt(n.firstChild) + const c = _t(Ye ? e : n) + for (; (i = c.nextNode()); ) St(i) || (i.content instanceof s && Lt(i.content), Ct(i)) + if (Ye) return e + if (Fe) { + if (He) for (l = se.call(n.ownerDocument); n.firstChild; ) l.appendChild(n.firstChild) + else l = n + return (Se.shadowroot || Se.shadowrootmode) && (l = me.call(a, l, !0)), l + } + let m = Ie ? n.outerHTML : n.innerHTML + return ( + Ie && + Ne["!doctype"] && + n.ownerDocument && + n.ownerDocument.doctype && + n.ownerDocument.doctype.name && + _(q, n.ownerDocument.doctype.name) && + (m = "\n" + m), + ke && + u([fe, de, he], (e) => { + m = g(m, e, " ") + }), + ie && ze ? ie.createHTML(m) : m + ) + }), + (o.setConfig = function () { + mt(arguments.length > 0 && void 0 !== arguments[0] ? arguments[0] : {}), (Ue = !0) + }), + (o.clearConfig = function () { + ;(ct = null), (Ue = !1) + }), + (o.isValidAttribute = function (e, t, n) { + ct || mt({}) + const o = lt(e), + r = lt(t) + return Rt(o, r, n) + }), + (o.addHook = function (e, t) { + "function" == typeof t && ((pe[e] = pe[e] || []), p(pe[e], t)) + }), + (o.removeHook = function (e) { + if (pe[e]) return m(pe[e]) + }), + (o.removeHooks = function (e) { + pe[e] && (pe[e] = []) + }), + (o.removeAllHooks = function () { + pe = {} + }), + o + ) + })() + return ne +}) +//# sourceMappingURL=purify.min.js.map diff --git a/self-hosted-cloudapi/src/web/static/vendor/socket.io.min.js b/self-hosted-cloudapi/src/web/static/vendor/socket.io.min.js new file mode 100644 index 0000000000..ce3cab5ee2 --- /dev/null +++ b/self-hosted-cloudapi/src/web/static/vendor/socket.io.min.js @@ -0,0 +1,2369 @@ +/*! + * Socket.IO v4.8.3 + * (c) 2014-2025 Guillermo Rauch + * Released under the MIT License. + */ +!(function (t, n) { + "object" == typeof exports && "undefined" != typeof module + ? (module.exports = n()) + : "function" == typeof define && define.amd + ? define(n) + : ((t = "undefined" != typeof globalThis ? globalThis : t || self).io = n()) +})(this, function () { + "use strict" + function t(t, n) { + ;(null == n || n > t.length) && (n = t.length) + for (var i = 0, r = Array(n); i < n; i++) r[i] = t[i] + return r + } + function n(t, n) { + for (var i = 0; i < n.length; i++) { + var r = n[i] + ;(r.enumerable = r.enumerable || !1), + (r.configurable = !0), + "value" in r && (r.writable = !0), + Object.defineProperty(t, f(r.key), r) + } + } + function i(t, i, r) { + return i && n(t.prototype, i), r && n(t, r), Object.defineProperty(t, "prototype", { writable: !1 }), t + } + function r(n, i) { + var r = ("undefined" != typeof Symbol && n[Symbol.iterator]) || n["@@iterator"] + if (!r) { + if ( + Array.isArray(n) || + (r = (function (n, i) { + if (n) { + if ("string" == typeof n) return t(n, i) + var r = {}.toString.call(n).slice(8, -1) + return ( + "Object" === r && n.constructor && (r = n.constructor.name), + "Map" === r || "Set" === r + ? Array.from(n) + : "Arguments" === r || /^(?:Ui|I)nt(?:8|16|32)(?:Clamped)?Array$/.test(r) + ? t(n, i) + : void 0 + ) + } + })(n)) || + (i && n && "number" == typeof n.length) + ) { + r && (n = r) + var e = 0, + o = function () {} + return { + s: o, + n: function () { + return e >= n.length ? { done: !0 } : { done: !1, value: n[e++] } + }, + e: function (t) { + throw t + }, + f: o, + } + } + throw new TypeError( + "Invalid attempt to iterate non-iterable instance.\nIn order to be iterable, non-array objects must have a [Symbol.iterator]() method.", + ) + } + var s, + u = !0, + h = !1 + return { + s: function () { + r = r.call(n) + }, + n: function () { + var t = r.next() + return (u = t.done), t + }, + e: function (t) { + ;(h = !0), (s = t) + }, + f: function () { + try { + u || null == r.return || r.return() + } finally { + if (h) throw s + } + }, + } + } + function e() { + return ( + (e = Object.assign + ? Object.assign.bind() + : function (t) { + for (var n = 1; n < arguments.length; n++) { + var i = arguments[n] + for (var r in i) ({}).hasOwnProperty.call(i, r) && (t[r] = i[r]) + } + return t + }), + e.apply(null, arguments) + ) + } + function o(t) { + return ( + (o = Object.setPrototypeOf + ? Object.getPrototypeOf.bind() + : function (t) { + return t.__proto__ || Object.getPrototypeOf(t) + }), + o(t) + ) + } + function s(t, n) { + ;(t.prototype = Object.create(n.prototype)), (t.prototype.constructor = t), h(t, n) + } + function u() { + try { + var t = !Boolean.prototype.valueOf.call(Reflect.construct(Boolean, [], function () {})) + } catch (t) {} + return (u = function () { + return !!t + })() + } + function h(t, n) { + return ( + (h = Object.setPrototypeOf + ? Object.setPrototypeOf.bind() + : function (t, n) { + return (t.__proto__ = n), t + }), + h(t, n) + ) + } + function f(t) { + var n = (function (t, n) { + if ("object" != typeof t || !t) return t + var i = t[Symbol.toPrimitive] + if (void 0 !== i) { + var r = i.call(t, n || "default") + if ("object" != typeof r) return r + throw new TypeError("@@toPrimitive must return a primitive value.") + } + return ("string" === n ? String : Number)(t) + })(t, "string") + return "symbol" == typeof n ? n : n + "" + } + function c(t) { + return ( + (c = + "function" == typeof Symbol && "symbol" == typeof Symbol.iterator + ? function (t) { + return typeof t + } + : function (t) { + return t && + "function" == typeof Symbol && + t.constructor === Symbol && + t !== Symbol.prototype + ? "symbol" + : typeof t + }), + c(t) + ) + } + function a(t) { + var n = "function" == typeof Map ? new Map() : void 0 + return ( + (a = function (t) { + if ( + null === t || + !(function (t) { + try { + return -1 !== Function.toString.call(t).indexOf("[native code]") + } catch (n) { + return "function" == typeof t + } + })(t) + ) + return t + if ("function" != typeof t) throw new TypeError("Super expression must either be null or a function") + if (void 0 !== n) { + if (n.has(t)) return n.get(t) + n.set(t, i) + } + function i() { + return (function (t, n, i) { + if (u()) return Reflect.construct.apply(null, arguments) + var r = [null] + r.push.apply(r, n) + var e = new (t.bind.apply(t, r))() + return i && h(e, i.prototype), e + })(t, arguments, o(this).constructor) + } + return ( + (i.prototype = Object.create(t.prototype, { + constructor: { value: i, enumerable: !1, writable: !0, configurable: !0 }, + })), + h(i, t) + ) + }), + a(t) + ) + } + var v = Object.create(null) + ;(v.open = "0"), + (v.close = "1"), + (v.ping = "2"), + (v.pong = "3"), + (v.message = "4"), + (v.upgrade = "5"), + (v.noop = "6") + var l = Object.create(null) + Object.keys(v).forEach(function (t) { + l[v[t]] = t + }) + var p, + d = { type: "error", data: "parser error" }, + y = + "function" == typeof Blob || + ("undefined" != typeof Blob && "[object BlobConstructor]" === Object.prototype.toString.call(Blob)), + b = "function" == typeof ArrayBuffer, + w = function (t) { + return "function" == typeof ArrayBuffer.isView + ? ArrayBuffer.isView(t) + : t && t.buffer instanceof ArrayBuffer + }, + g = function (t, n, i) { + var r = t.type, + e = t.data + return y && e instanceof Blob + ? n + ? i(e) + : m(e, i) + : b && (e instanceof ArrayBuffer || w(e)) + ? n + ? i(e) + : m(new Blob([e]), i) + : i(v[r] + (e || "")) + }, + m = function (t, n) { + var i = new FileReader() + return ( + (i.onload = function () { + var t = i.result.split(",")[1] + n("b" + (t || "")) + }), + i.readAsDataURL(t) + ) + } + function k(t) { + return t instanceof Uint8Array + ? t + : t instanceof ArrayBuffer + ? new Uint8Array(t) + : new Uint8Array(t.buffer, t.byteOffset, t.byteLength) + } + for ( + var A = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/", + j = "undefined" == typeof Uint8Array ? [] : new Uint8Array(256), + E = 0; + E < 64; + E++ + ) + j[A.charCodeAt(E)] = E + var O, + B = "function" == typeof ArrayBuffer, + S = function (t, n) { + if ("string" != typeof t) return { type: "message", data: C(t, n) } + var i = t.charAt(0) + return "b" === i + ? { type: "message", data: N(t.substring(1), n) } + : l[i] + ? t.length > 1 + ? { type: l[i], data: t.substring(1) } + : { type: l[i] } + : d + }, + N = function (t, n) { + if (B) { + var i = (function (t) { + var n, + i, + r, + e, + o, + s = 0.75 * t.length, + u = t.length, + h = 0 + "=" === t[t.length - 1] && (s--, "=" === t[t.length - 2] && s--) + var f = new ArrayBuffer(s), + c = new Uint8Array(f) + for (n = 0; n < u; n += 4) + (i = j[t.charCodeAt(n)]), + (r = j[t.charCodeAt(n + 1)]), + (e = j[t.charCodeAt(n + 2)]), + (o = j[t.charCodeAt(n + 3)]), + (c[h++] = (i << 2) | (r >> 4)), + (c[h++] = ((15 & r) << 4) | (e >> 2)), + (c[h++] = ((3 & e) << 6) | (63 & o)) + return f + })(t) + return C(i, n) + } + return { base64: !0, data: t } + }, + C = function (t, n) { + return "blob" === n ? (t instanceof Blob ? t : new Blob([t])) : t instanceof ArrayBuffer ? t : t.buffer + }, + T = String.fromCharCode(30) + function U() { + return new TransformStream({ + transform: function (t, n) { + !(function (t, n) { + y && t.data instanceof Blob + ? t.data.arrayBuffer().then(k).then(n) + : b && (t.data instanceof ArrayBuffer || w(t.data)) + ? n(k(t.data)) + : g(t, !1, function (t) { + p || (p = new TextEncoder()), n(p.encode(t)) + }) + })(t, function (i) { + var r, + e = i.length + if (e < 126) (r = new Uint8Array(1)), new DataView(r.buffer).setUint8(0, e) + else if (e < 65536) { + r = new Uint8Array(3) + var o = new DataView(r.buffer) + o.setUint8(0, 126), o.setUint16(1, e) + } else { + r = new Uint8Array(9) + var s = new DataView(r.buffer) + s.setUint8(0, 127), s.setBigUint64(1, BigInt(e)) + } + t.data && "string" != typeof t.data && (r[0] |= 128), n.enqueue(r), n.enqueue(i) + }) + }, + }) + } + function M(t) { + return t.reduce(function (t, n) { + return t + n.length + }, 0) + } + function x(t, n) { + if (t[0].length === n) return t.shift() + for (var i = new Uint8Array(n), r = 0, e = 0; e < n; e++) + (i[e] = t[0][r++]), r === t[0].length && (t.shift(), (r = 0)) + return t.length && r < t[0].length && (t[0] = t[0].slice(r)), i + } + function I(t) { + if (t) + return (function (t) { + for (var n in I.prototype) t[n] = I.prototype[n] + return t + })(t) + } + ;(I.prototype.on = I.prototype.addEventListener = + function (t, n) { + return (this.t = this.t || {}), (this.t["$" + t] = this.t["$" + t] || []).push(n), this + }), + (I.prototype.once = function (t, n) { + function i() { + this.off(t, i), n.apply(this, arguments) + } + return (i.fn = n), this.on(t, i), this + }), + (I.prototype.off = + I.prototype.removeListener = + I.prototype.removeAllListeners = + I.prototype.removeEventListener = + function (t, n) { + if (((this.t = this.t || {}), 0 == arguments.length)) return (this.t = {}), this + var i, + r = this.t["$" + t] + if (!r) return this + if (1 == arguments.length) return delete this.t["$" + t], this + for (var e = 0; e < r.length; e++) + if ((i = r[e]) === n || i.fn === n) { + r.splice(e, 1) + break + } + return 0 === r.length && delete this.t["$" + t], this + }), + (I.prototype.emit = function (t) { + this.t = this.t || {} + for (var n = new Array(arguments.length - 1), i = this.t["$" + t], r = 1; r < arguments.length; r++) + n[r - 1] = arguments[r] + if (i) { + r = 0 + for (var e = (i = i.slice(0)).length; r < e; ++r) i[r].apply(this, n) + } + return this + }), + (I.prototype.emitReserved = I.prototype.emit), + (I.prototype.listeners = function (t) { + return (this.t = this.t || {}), this.t["$" + t] || [] + }), + (I.prototype.hasListeners = function (t) { + return !!this.listeners(t).length + }) + var R = + "function" == typeof Promise && "function" == typeof Promise.resolve + ? function (t) { + return Promise.resolve().then(t) + } + : function (t, n) { + return n(t, 0) + }, + L = "undefined" != typeof self ? self : "undefined" != typeof window ? window : Function("return this")() + function _(t) { + for (var n = arguments.length, i = new Array(n > 1 ? n - 1 : 0), r = 1; r < n; r++) i[r - 1] = arguments[r] + return i.reduce(function (n, i) { + return t.hasOwnProperty(i) && (n[i] = t[i]), n + }, {}) + } + var D = L.setTimeout, + P = L.clearTimeout + function $(t, n) { + n.useNativeTimers + ? ((t.setTimeoutFn = D.bind(L)), (t.clearTimeoutFn = P.bind(L))) + : ((t.setTimeoutFn = L.setTimeout.bind(L)), (t.clearTimeoutFn = L.clearTimeout.bind(L))) + } + function F() { + return Date.now().toString(36).substring(3) + Math.random().toString(36).substring(2, 5) + } + var V = (function (t) { + function n(n, i, r) { + var e + return ((e = t.call(this, n) || this).description = i), (e.context = r), (e.type = "TransportError"), e + } + return s(n, t), n + })(a(Error)), + q = (function (t) { + function n(n) { + var i + return ( + ((i = t.call(this) || this).writable = !1), + $(i, n), + (i.opts = n), + (i.query = n.query), + (i.socket = n.socket), + (i.supportsBinary = !n.forceBase64), + i + ) + } + s(n, t) + var i = n.prototype + return ( + (i.onError = function (n, i, r) { + return t.prototype.emitReserved.call(this, "error", new V(n, i, r)), this + }), + (i.open = function () { + return (this.readyState = "opening"), this.doOpen(), this + }), + (i.close = function () { + return ( + ("opening" !== this.readyState && "open" !== this.readyState) || + (this.doClose(), this.onClose()), + this + ) + }), + (i.send = function (t) { + "open" === this.readyState && this.write(t) + }), + (i.onOpen = function () { + ;(this.readyState = "open"), (this.writable = !0), t.prototype.emitReserved.call(this, "open") + }), + (i.onData = function (t) { + var n = S(t, this.socket.binaryType) + this.onPacket(n) + }), + (i.onPacket = function (n) { + t.prototype.emitReserved.call(this, "packet", n) + }), + (i.onClose = function (n) { + ;(this.readyState = "closed"), t.prototype.emitReserved.call(this, "close", n) + }), + (i.pause = function (t) {}), + (i.createUri = function (t) { + var n = arguments.length > 1 && void 0 !== arguments[1] ? arguments[1] : {} + return t + "://" + this.i() + this.o() + this.opts.path + this.u(n) + }), + (i.i = function () { + var t = this.opts.hostname + return -1 === t.indexOf(":") ? t : "[" + t + "]" + }), + (i.o = function () { + return this.opts.port && + ((this.opts.secure && 443 !== Number(this.opts.port)) || + (!this.opts.secure && 80 !== Number(this.opts.port))) + ? ":" + this.opts.port + : "" + }), + (i.u = function (t) { + var n = (function (t) { + var n = "" + for (var i in t) + t.hasOwnProperty(i) && + (n.length && (n += "&"), (n += encodeURIComponent(i) + "=" + encodeURIComponent(t[i]))) + return n + })(t) + return n.length ? "?" + n : "" + }), + n + ) + })(I), + X = (function (t) { + function n() { + var n + return ((n = t.apply(this, arguments) || this).h = !1), n + } + s(n, t) + var r = n.prototype + return ( + (r.doOpen = function () { + this.v() + }), + (r.pause = function (t) { + var n = this + this.readyState = "pausing" + var i = function () { + ;(n.readyState = "paused"), t() + } + if (this.h || !this.writable) { + var r = 0 + this.h && + (r++, + this.once("pollComplete", function () { + --r || i() + })), + this.writable || + (r++, + this.once("drain", function () { + --r || i() + })) + } else i() + }), + (r.v = function () { + ;(this.h = !0), this.doPoll(), this.emitReserved("poll") + }), + (r.onData = function (t) { + var n = this + ;(function (t, n) { + for (var i = t.split(T), r = [], e = 0; e < i.length; e++) { + var o = S(i[e], n) + if ((r.push(o), "error" === o.type)) break + } + return r + })(t, this.socket.binaryType).forEach(function (t) { + if (("opening" === n.readyState && "open" === t.type && n.onOpen(), "close" === t.type)) + return n.onClose({ description: "transport closed by the server" }), !1 + n.onPacket(t) + }), + "closed" !== this.readyState && + ((this.h = !1), this.emitReserved("pollComplete"), "open" === this.readyState && this.v()) + }), + (r.doClose = function () { + var t = this, + n = function () { + t.write([{ type: "close" }]) + } + "open" === this.readyState ? n() : this.once("open", n) + }), + (r.write = function (t) { + var n = this + ;(this.writable = !1), + (function (t, n) { + var i = t.length, + r = new Array(i), + e = 0 + t.forEach(function (t, o) { + g(t, !1, function (t) { + ;(r[o] = t), ++e === i && n(r.join(T)) + }) + }) + })(t, function (t) { + n.doWrite(t, function () { + ;(n.writable = !0), n.emitReserved("drain") + }) + }) + }), + (r.uri = function () { + var t = this.opts.secure ? "https" : "http", + n = this.query || {} + return ( + !1 !== this.opts.timestampRequests && (n[this.opts.timestampParam] = F()), + this.supportsBinary || n.sid || (n.b64 = 1), + this.createUri(t, n) + ) + }), + i(n, [ + { + key: "name", + get: function () { + return "polling" + }, + }, + ]) + ) + })(q), + H = !1 + try { + H = "undefined" != typeof XMLHttpRequest && "withCredentials" in new XMLHttpRequest() + } catch (t) {} + var z = H + function J() {} + var K = (function (t) { + function n(n) { + var i + if (((i = t.call(this, n) || this), "undefined" != typeof location)) { + var r = "https:" === location.protocol, + e = location.port + e || (e = r ? "443" : "80"), + (i.xd = ("undefined" != typeof location && n.hostname !== location.hostname) || e !== n.port) + } + return i + } + s(n, t) + var i = n.prototype + return ( + (i.doWrite = function (t, n) { + var i = this, + r = this.request({ method: "POST", data: t }) + r.on("success", n), + r.on("error", function (t, n) { + i.onError("xhr post error", t, n) + }) + }), + (i.doPoll = function () { + var t = this, + n = this.request() + n.on("data", this.onData.bind(this)), + n.on("error", function (n, i) { + t.onError("xhr poll error", n, i) + }), + (this.pollXhr = n) + }), + n + ) + })(X), + Y = (function (t) { + function n(n, i, r) { + var e + return ( + ((e = t.call(this) || this).createRequest = n), + $(e, r), + (e.l = r), + (e.p = r.method || "GET"), + (e.m = i), + (e.k = void 0 !== r.data ? r.data : null), + e.A(), + e + ) + } + s(n, t) + var i = n.prototype + return ( + (i.A = function () { + var t, + i = this, + r = _( + this.l, + "agent", + "pfx", + "key", + "passphrase", + "cert", + "ca", + "ciphers", + "rejectUnauthorized", + "autoUnref", + ) + r.xdomain = !!this.l.xd + var e = (this.j = this.createRequest(r)) + try { + e.open(this.p, this.m, !0) + try { + if (this.l.extraHeaders) + for (var o in (e.setDisableHeaderCheck && e.setDisableHeaderCheck(!0), + this.l.extraHeaders)) + this.l.extraHeaders.hasOwnProperty(o) && + e.setRequestHeader(o, this.l.extraHeaders[o]) + } catch (t) {} + if ("POST" === this.p) + try { + e.setRequestHeader("Content-type", "text/plain;charset=UTF-8") + } catch (t) {} + try { + e.setRequestHeader("Accept", "*/*") + } catch (t) {} + null === (t = this.l.cookieJar) || void 0 === t || t.addCookies(e), + "withCredentials" in e && (e.withCredentials = this.l.withCredentials), + this.l.requestTimeout && (e.timeout = this.l.requestTimeout), + (e.onreadystatechange = function () { + var t + 3 === e.readyState && + (null === (t = i.l.cookieJar) || + void 0 === t || + t.parseCookies(e.getResponseHeader("set-cookie"))), + 4 === e.readyState && + (200 === e.status || 1223 === e.status + ? i.O() + : i.setTimeoutFn(function () { + i.B("number" == typeof e.status ? e.status : 0) + }, 0)) + }), + e.send(this.k) + } catch (t) { + return void this.setTimeoutFn(function () { + i.B(t) + }, 0) + } + "undefined" != typeof document && ((this.S = n.requestsCount++), (n.requests[this.S] = this)) + }), + (i.B = function (t) { + this.emitReserved("error", t, this.j), this.N(!0) + }), + (i.N = function (t) { + if (void 0 !== this.j && null !== this.j) { + if (((this.j.onreadystatechange = J), t)) + try { + this.j.abort() + } catch (t) {} + "undefined" != typeof document && delete n.requests[this.S], (this.j = null) + } + }), + (i.O = function () { + var t = this.j.responseText + null !== t && (this.emitReserved("data", t), this.emitReserved("success"), this.N()) + }), + (i.abort = function () { + this.N() + }), + n + ) + })(I) + if (((Y.requestsCount = 0), (Y.requests = {}), "undefined" != typeof document)) + if ("function" == typeof attachEvent) attachEvent("onunload", G) + else if ("function" == typeof addEventListener) { + addEventListener("onpagehide" in L ? "pagehide" : "unload", G, !1) + } + function G() { + for (var t in Y.requests) Y.requests.hasOwnProperty(t) && Y.requests[t].abort() + } + var Q, + W = (Q = tt({ xdomain: !1 })) && null !== Q.responseType, + Z = (function (t) { + function n(n) { + var i + i = t.call(this, n) || this + var r = n && n.forceBase64 + return (i.supportsBinary = W && !r), i + } + return ( + s(n, t), + (n.prototype.request = function () { + var t = arguments.length > 0 && void 0 !== arguments[0] ? arguments[0] : {} + return e(t, { xd: this.xd }, this.opts), new Y(tt, this.uri(), t) + }), + n + ) + })(K) + function tt(t) { + var n = t.xdomain + try { + if ("undefined" != typeof XMLHttpRequest && (!n || z)) return new XMLHttpRequest() + } catch (t) {} + if (!n) + try { + return new L[["Active"].concat("Object").join("X")]("Microsoft.XMLHTTP") + } catch (t) {} + } + var nt = + "undefined" != typeof navigator && + "string" == typeof navigator.product && + "reactnative" === navigator.product.toLowerCase(), + it = (function (t) { + function n() { + return t.apply(this, arguments) || this + } + s(n, t) + var r = n.prototype + return ( + (r.doOpen = function () { + var t = this.uri(), + n = this.opts.protocols, + i = nt + ? {} + : _( + this.opts, + "agent", + "perMessageDeflate", + "pfx", + "key", + "passphrase", + "cert", + "ca", + "ciphers", + "rejectUnauthorized", + "localAddress", + "protocolVersion", + "origin", + "maxPayload", + "family", + "checkServerIdentity", + ) + this.opts.extraHeaders && (i.headers = this.opts.extraHeaders) + try { + this.ws = this.createSocket(t, n, i) + } catch (t) { + return this.emitReserved("error", t) + } + ;(this.ws.binaryType = this.socket.binaryType), this.addEventListeners() + }), + (r.addEventListeners = function () { + var t = this + ;(this.ws.onopen = function () { + t.opts.autoUnref && t.ws.C.unref(), t.onOpen() + }), + (this.ws.onclose = function (n) { + return t.onClose({ description: "websocket connection closed", context: n }) + }), + (this.ws.onmessage = function (n) { + return t.onData(n.data) + }), + (this.ws.onerror = function (n) { + return t.onError("websocket error", n) + }) + }), + (r.write = function (t) { + var n = this + this.writable = !1 + for ( + var i = function () { + var i = t[r], + e = r === t.length - 1 + g(i, n.supportsBinary, function (t) { + try { + n.doWrite(i, t) + } catch (t) {} + e && + R(function () { + ;(n.writable = !0), n.emitReserved("drain") + }, n.setTimeoutFn) + }) + }, + r = 0; + r < t.length; + r++ + ) + i() + }), + (r.doClose = function () { + void 0 !== this.ws && ((this.ws.onerror = function () {}), this.ws.close(), (this.ws = null)) + }), + (r.uri = function () { + var t = this.opts.secure ? "wss" : "ws", + n = this.query || {} + return ( + this.opts.timestampRequests && (n[this.opts.timestampParam] = F()), + this.supportsBinary || (n.b64 = 1), + this.createUri(t, n) + ) + }), + i(n, [ + { + key: "name", + get: function () { + return "websocket" + }, + }, + ]) + ) + })(q), + rt = L.WebSocket || L.MozWebSocket, + et = (function (t) { + function n() { + return t.apply(this, arguments) || this + } + s(n, t) + var i = n.prototype + return ( + (i.createSocket = function (t, n, i) { + return nt ? new rt(t, n, i) : n ? new rt(t, n) : new rt(t) + }), + (i.doWrite = function (t, n) { + this.ws.send(n) + }), + n + ) + })(it), + ot = (function (t) { + function n() { + return t.apply(this, arguments) || this + } + s(n, t) + var r = n.prototype + return ( + (r.doOpen = function () { + var t = this + try { + this.T = new WebTransport(this.createUri("https"), this.opts.transportOptions[this.name]) + } catch (t) { + return this.emitReserved("error", t) + } + this.T.closed + .then(function () { + t.onClose() + }) + .catch(function (n) { + t.onError("webtransport error", n) + }), + this.T.ready.then(function () { + t.T.createBidirectionalStream().then(function (n) { + var i = (function (t, n) { + O || (O = new TextDecoder()) + var i = [], + r = 0, + e = -1, + o = !1 + return new TransformStream({ + transform: function (s, u) { + for (i.push(s); ; ) { + if (0 === r) { + if (M(i) < 1) break + var h = x(i, 1) + ;(o = !(128 & ~h[0])), + (e = 127 & h[0]), + (r = e < 126 ? 3 : 126 === e ? 1 : 2) + } else if (1 === r) { + if (M(i) < 2) break + var f = x(i, 2) + ;(e = new DataView(f.buffer, f.byteOffset, f.length).getUint16( + 0, + )), + (r = 3) + } else if (2 === r) { + if (M(i) < 8) break + var c = x(i, 8), + a = new DataView(c.buffer, c.byteOffset, c.length), + v = a.getUint32(0) + if (v > Math.pow(2, 21) - 1) { + u.enqueue(d) + break + } + ;(e = v * Math.pow(2, 32) + a.getUint32(4)), (r = 3) + } else { + if (M(i) < e) break + var l = x(i, e) + u.enqueue(S(o ? l : O.decode(l), n)), (r = 0) + } + if (0 === e || e > t) { + u.enqueue(d) + break + } + } + }, + }) + })(Number.MAX_SAFE_INTEGER, t.socket.binaryType), + r = n.readable.pipeThrough(i).getReader(), + e = U() + e.readable.pipeTo(n.writable), (t.U = e.writable.getWriter()) + !(function n() { + r.read() + .then(function (i) { + var r = i.done, + e = i.value + r || (t.onPacket(e), n()) + }) + .catch(function (t) {}) + })() + var o = { type: "open" } + t.query.sid && (o.data = '{"sid":"'.concat(t.query.sid, '"}')), + t.U.write(o).then(function () { + return t.onOpen() + }) + }) + }) + }), + (r.write = function (t) { + var n = this + this.writable = !1 + for ( + var i = function () { + var i = t[r], + e = r === t.length - 1 + n.U.write(i).then(function () { + e && + R(function () { + ;(n.writable = !0), n.emitReserved("drain") + }, n.setTimeoutFn) + }) + }, + r = 0; + r < t.length; + r++ + ) + i() + }), + (r.doClose = function () { + var t + null === (t = this.T) || void 0 === t || t.close() + }), + i(n, [ + { + key: "name", + get: function () { + return "webtransport" + }, + }, + ]) + ) + })(q), + st = { websocket: et, webtransport: ot, polling: Z }, + ut = + /^(?:(?![^:@\/?#]+:[^:@\/]*@)(http|https|ws|wss):\/\/)?((?:(([^:@\/?#]*)(?::([^:@\/?#]*))?)?@)?((?:[a-f0-9]{0,4}:){2,7}[a-f0-9]{0,4}|[^:\/?#]*)(?::(\d*))?)(((\/(?:[^?#](?![^?#\/]*\.[^?#\/.]+(?:[?#]|$)))*\/?)?([^?#\/]*))(?:\?([^#]*))?(?:#(.*))?)/, + ht = [ + "source", + "protocol", + "authority", + "userInfo", + "user", + "password", + "host", + "port", + "relative", + "path", + "directory", + "file", + "query", + "anchor", + ] + function ft(t) { + if (t.length > 8e3) throw "URI too long" + var n = t, + i = t.indexOf("["), + r = t.indexOf("]") + ;-1 != i && -1 != r && (t = t.substring(0, i) + t.substring(i, r).replace(/:/g, ";") + t.substring(r, t.length)) + for (var e, o, s = ut.exec(t || ""), u = {}, h = 14; h--; ) u[ht[h]] = s[h] || "" + return ( + -1 != i && + -1 != r && + ((u.source = n), + (u.host = u.host.substring(1, u.host.length - 1).replace(/;/g, ":")), + (u.authority = u.authority.replace("[", "").replace("]", "").replace(/;/g, ":")), + (u.ipv6uri = !0)), + (u.pathNames = (function (t, n) { + var i = /\/{2,9}/g, + r = n.replace(i, "/").split("/") + ;("/" != n.slice(0, 1) && 0 !== n.length) || r.splice(0, 1) + "/" == n.slice(-1) && r.splice(r.length - 1, 1) + return r + })(0, u.path)), + (u.queryKey = + ((e = u.query), + (o = {}), + e.replace(/(?:^|&)([^&=]*)=?([^&]*)/g, function (t, n, i) { + n && (o[n] = i) + }), + o)), + u + ) + } + var ct = "function" == typeof addEventListener && "function" == typeof removeEventListener, + at = [] + ct && + addEventListener( + "offline", + function () { + at.forEach(function (t) { + return t() + }) + }, + !1, + ) + var vt = (function (t) { + function n(n, i) { + var r + if ( + (((r = t.call(this) || this).binaryType = "arraybuffer"), + (r.writeBuffer = []), + (r.M = 0), + (r.I = -1), + (r.R = -1), + (r.L = -1), + (r._ = 1 / 0), + n && "object" === c(n) && ((i = n), (n = null)), + n) + ) { + var o = ft(n) + ;(i.hostname = o.host), + (i.secure = "https" === o.protocol || "wss" === o.protocol), + (i.port = o.port), + o.query && (i.query = o.query) + } else i.host && (i.hostname = ft(i.host).host) + return ( + $(r, i), + (r.secure = + null != i.secure ? i.secure : "undefined" != typeof location && "https:" === location.protocol), + i.hostname && !i.port && (i.port = r.secure ? "443" : "80"), + (r.hostname = i.hostname || ("undefined" != typeof location ? location.hostname : "localhost")), + (r.port = + i.port || + ("undefined" != typeof location && location.port ? location.port : r.secure ? "443" : "80")), + (r.transports = []), + (r.D = {}), + i.transports.forEach(function (t) { + var n = t.prototype.name + r.transports.push(n), (r.D[n] = t) + }), + (r.opts = e( + { + path: "/engine.io", + agent: !1, + withCredentials: !1, + upgrade: !0, + timestampParam: "t", + rememberUpgrade: !1, + addTrailingSlash: !0, + rejectUnauthorized: !0, + perMessageDeflate: { threshold: 1024 }, + transportOptions: {}, + closeOnBeforeunload: !1, + }, + i, + )), + (r.opts.path = r.opts.path.replace(/\/$/, "") + (r.opts.addTrailingSlash ? "/" : "")), + "string" == typeof r.opts.query && + (r.opts.query = (function (t) { + for (var n = {}, i = t.split("&"), r = 0, e = i.length; r < e; r++) { + var o = i[r].split("=") + n[decodeURIComponent(o[0])] = decodeURIComponent(o[1]) + } + return n + })(r.opts.query)), + ct && + (r.opts.closeOnBeforeunload && + ((r.P = function () { + r.transport && (r.transport.removeAllListeners(), r.transport.close()) + }), + addEventListener("beforeunload", r.P, !1)), + "localhost" !== r.hostname && + ((r.$ = function () { + r.F("transport close", { description: "network connection lost" }) + }), + at.push(r.$))), + r.opts.withCredentials && (r.V = void 0), + r.q(), + r + ) + } + s(n, t) + var i = n.prototype + return ( + (i.createTransport = function (t) { + var n = e({}, this.opts.query) + ;(n.EIO = 4), (n.transport = t), this.id && (n.sid = this.id) + var i = e( + {}, + this.opts, + { query: n, socket: this, hostname: this.hostname, secure: this.secure, port: this.port }, + this.opts.transportOptions[t], + ) + return new this.D[t](i) + }), + (i.q = function () { + var t = this + if (0 !== this.transports.length) { + var i = + this.opts.rememberUpgrade && + n.priorWebsocketSuccess && + -1 !== this.transports.indexOf("websocket") + ? "websocket" + : this.transports[0] + this.readyState = "opening" + var r = this.createTransport(i) + r.open(), this.setTransport(r) + } else + this.setTimeoutFn(function () { + t.emitReserved("error", "No transports available") + }, 0) + }), + (i.setTransport = function (t) { + var n = this + this.transport && this.transport.removeAllListeners(), + (this.transport = t), + t + .on("drain", this.X.bind(this)) + .on("packet", this.H.bind(this)) + .on("error", this.B.bind(this)) + .on("close", function (t) { + return n.F("transport close", t) + }) + }), + (i.onOpen = function () { + ;(this.readyState = "open"), + (n.priorWebsocketSuccess = "websocket" === this.transport.name), + this.emitReserved("open"), + this.flush() + }), + (i.H = function (t) { + if ("opening" === this.readyState || "open" === this.readyState || "closing" === this.readyState) + switch ((this.emitReserved("packet", t), this.emitReserved("heartbeat"), t.type)) { + case "open": + this.onHandshake(JSON.parse(t.data)) + break + case "ping": + this.J("pong"), this.emitReserved("ping"), this.emitReserved("pong"), this.K() + break + case "error": + var n = new Error("server error") + ;(n.code = t.data), this.B(n) + break + case "message": + this.emitReserved("data", t.data), this.emitReserved("message", t.data) + } + }), + (i.onHandshake = function (t) { + this.emitReserved("handshake", t), + (this.id = t.sid), + (this.transport.query.sid = t.sid), + (this.I = t.pingInterval), + (this.R = t.pingTimeout), + (this.L = t.maxPayload), + this.onOpen(), + "closed" !== this.readyState && this.K() + }), + (i.K = function () { + var t = this + this.clearTimeoutFn(this.Y) + var n = this.I + this.R + ;(this._ = Date.now() + n), + (this.Y = this.setTimeoutFn(function () { + t.F("ping timeout") + }, n)), + this.opts.autoUnref && this.Y.unref() + }), + (i.X = function () { + this.writeBuffer.splice(0, this.M), + (this.M = 0), + 0 === this.writeBuffer.length ? this.emitReserved("drain") : this.flush() + }), + (i.flush = function () { + if ( + "closed" !== this.readyState && + this.transport.writable && + !this.upgrading && + this.writeBuffer.length + ) { + var t = this.G() + this.transport.send(t), (this.M = t.length), this.emitReserved("flush") + } + }), + (i.G = function () { + if (!(this.L && "polling" === this.transport.name && this.writeBuffer.length > 1)) + return this.writeBuffer + for (var t, n = 1, i = 0; i < this.writeBuffer.length; i++) { + var r = this.writeBuffer[i].data + if ( + (r && + (n += + "string" == typeof (t = r) + ? (function (t) { + for (var n = 0, i = 0, r = 0, e = t.length; r < e; r++) + (n = t.charCodeAt(r)) < 128 + ? (i += 1) + : n < 2048 + ? (i += 2) + : n < 55296 || n >= 57344 + ? (i += 3) + : (r++, (i += 4)) + return i + })(t) + : Math.ceil(1.33 * (t.byteLength || t.size))), + i > 0 && n > this.L) + ) + return this.writeBuffer.slice(0, i) + n += 2 + } + return this.writeBuffer + }), + (i.W = function () { + var t = this + if (!this._) return !0 + var n = Date.now() > this._ + return ( + n && + ((this._ = 0), + R(function () { + t.F("ping timeout") + }, this.setTimeoutFn)), + n + ) + }), + (i.write = function (t, n, i) { + return this.J("message", t, n, i), this + }), + (i.send = function (t, n, i) { + return this.J("message", t, n, i), this + }), + (i.J = function (t, n, i, r) { + if ( + ("function" == typeof n && ((r = n), (n = void 0)), + "function" == typeof i && ((r = i), (i = null)), + "closing" !== this.readyState && "closed" !== this.readyState) + ) { + ;(i = i || {}).compress = !1 !== i.compress + var e = { type: t, data: n, options: i } + this.emitReserved("packetCreate", e), + this.writeBuffer.push(e), + r && this.once("flush", r), + this.flush() + } + }), + (i.close = function () { + var t = this, + n = function () { + t.F("forced close"), t.transport.close() + }, + i = function i() { + t.off("upgrade", i), t.off("upgradeError", i), n() + }, + r = function () { + t.once("upgrade", i), t.once("upgradeError", i) + } + return ( + ("opening" !== this.readyState && "open" !== this.readyState) || + ((this.readyState = "closing"), + this.writeBuffer.length + ? this.once("drain", function () { + t.upgrading ? r() : n() + }) + : this.upgrading + ? r() + : n()), + this + ) + }), + (i.B = function (t) { + if ( + ((n.priorWebsocketSuccess = !1), + this.opts.tryAllTransports && this.transports.length > 1 && "opening" === this.readyState) + ) + return this.transports.shift(), this.q() + this.emitReserved("error", t), this.F("transport error", t) + }), + (i.F = function (t, n) { + if ("opening" === this.readyState || "open" === this.readyState || "closing" === this.readyState) { + if ( + (this.clearTimeoutFn(this.Y), + this.transport.removeAllListeners("close"), + this.transport.close(), + this.transport.removeAllListeners(), + ct && (this.P && removeEventListener("beforeunload", this.P, !1), this.$)) + ) { + var i = at.indexOf(this.$) + ;-1 !== i && at.splice(i, 1) + } + ;(this.readyState = "closed"), + (this.id = null), + this.emitReserved("close", t, n), + (this.writeBuffer = []), + (this.M = 0) + } + }), + n + ) + })(I) + vt.protocol = 4 + var lt = (function (t) { + function n() { + var n + return ((n = t.apply(this, arguments) || this).Z = []), n + } + s(n, t) + var i = n.prototype + return ( + (i.onOpen = function () { + if ((t.prototype.onOpen.call(this), "open" === this.readyState && this.opts.upgrade)) + for (var n = 0; n < this.Z.length; n++) this.tt(this.Z[n]) + }), + (i.tt = function (t) { + var n = this, + i = this.createTransport(t), + r = !1 + vt.priorWebsocketSuccess = !1 + var e = function () { + r || + (i.send([{ type: "ping", data: "probe" }]), + i.once("packet", function (t) { + if (!r) + if ("pong" === t.type && "probe" === t.data) { + if (((n.upgrading = !0), n.emitReserved("upgrading", i), !i)) return + ;(vt.priorWebsocketSuccess = "websocket" === i.name), + n.transport.pause(function () { + r || + ("closed" !== n.readyState && + (c(), + n.setTransport(i), + i.send([{ type: "upgrade" }]), + n.emitReserved("upgrade", i), + (i = null), + (n.upgrading = !1), + n.flush())) + }) + } else { + var e = new Error("probe error") + ;(e.transport = i.name), n.emitReserved("upgradeError", e) + } + })) + } + function o() { + r || ((r = !0), c(), i.close(), (i = null)) + } + var s = function (t) { + var r = new Error("probe error: " + t) + ;(r.transport = i.name), o(), n.emitReserved("upgradeError", r) + } + function u() { + s("transport closed") + } + function h() { + s("socket closed") + } + function f(t) { + i && t.name !== i.name && o() + } + var c = function () { + i.removeListener("open", e), + i.removeListener("error", s), + i.removeListener("close", u), + n.off("close", h), + n.off("upgrading", f) + } + i.once("open", e), + i.once("error", s), + i.once("close", u), + this.once("close", h), + this.once("upgrading", f), + -1 !== this.Z.indexOf("webtransport") && "webtransport" !== t + ? this.setTimeoutFn(function () { + r || i.open() + }, 200) + : i.open() + }), + (i.onHandshake = function (n) { + ;(this.Z = this.nt(n.upgrades)), t.prototype.onHandshake.call(this, n) + }), + (i.nt = function (t) { + for (var n = [], i = 0; i < t.length; i++) ~this.transports.indexOf(t[i]) && n.push(t[i]) + return n + }), + n + ) + })(vt), + pt = (function (t) { + function n(n) { + var i = arguments.length > 1 && void 0 !== arguments[1] ? arguments[1] : {}, + r = "object" === c(n) ? n : i + return ( + (!r.transports || (r.transports && "string" == typeof r.transports[0])) && + (r.transports = (r.transports || ["polling", "websocket", "webtransport"]) + .map(function (t) { + return st[t] + }) + .filter(function (t) { + return !!t + })), + t.call(this, n, r) || this + ) + } + return s(n, t), n + })(lt) + pt.protocol + var dt = "function" == typeof ArrayBuffer, + yt = function (t) { + return "function" == typeof ArrayBuffer.isView ? ArrayBuffer.isView(t) : t.buffer instanceof ArrayBuffer + }, + bt = Object.prototype.toString, + wt = "function" == typeof Blob || ("undefined" != typeof Blob && "[object BlobConstructor]" === bt.call(Blob)), + gt = "function" == typeof File || ("undefined" != typeof File && "[object FileConstructor]" === bt.call(File)) + function mt(t) { + return (dt && (t instanceof ArrayBuffer || yt(t))) || (wt && t instanceof Blob) || (gt && t instanceof File) + } + function kt(t, n) { + if (!t || "object" !== c(t)) return !1 + if (Array.isArray(t)) { + for (var i = 0, r = t.length; i < r; i++) if (kt(t[i])) return !0 + return !1 + } + if (mt(t)) return !0 + if (t.toJSON && "function" == typeof t.toJSON && 1 === arguments.length) return kt(t.toJSON(), !0) + for (var e in t) if (Object.prototype.hasOwnProperty.call(t, e) && kt(t[e])) return !0 + return !1 + } + function At(t) { + var n = [], + i = t.data, + r = t + return (r.data = jt(i, n)), (r.attachments = n.length), { packet: r, buffers: n } + } + function jt(t, n) { + if (!t) return t + if (mt(t)) { + var i = { _placeholder: !0, num: n.length } + return n.push(t), i + } + if (Array.isArray(t)) { + for (var r = new Array(t.length), e = 0; e < t.length; e++) r[e] = jt(t[e], n) + return r + } + if ("object" === c(t) && !(t instanceof Date)) { + var o = {} + for (var s in t) Object.prototype.hasOwnProperty.call(t, s) && (o[s] = jt(t[s], n)) + return o + } + return t + } + function Et(t, n) { + return (t.data = Ot(t.data, n)), delete t.attachments, t + } + function Ot(t, n) { + if (!t) return t + if (t && !0 === t._placeholder) { + if ("number" == typeof t.num && t.num >= 0 && t.num < n.length) return n[t.num] + throw new Error("illegal attachments") + } + if (Array.isArray(t)) for (var i = 0; i < t.length; i++) t[i] = Ot(t[i], n) + else if ("object" === c(t)) for (var r in t) Object.prototype.hasOwnProperty.call(t, r) && (t[r] = Ot(t[r], n)) + return t + } + var Bt, + St = ["connect", "connect_error", "disconnect", "disconnecting", "newListener", "removeListener"] + !(function (t) { + ;(t[(t.CONNECT = 0)] = "CONNECT"), + (t[(t.DISCONNECT = 1)] = "DISCONNECT"), + (t[(t.EVENT = 2)] = "EVENT"), + (t[(t.ACK = 3)] = "ACK"), + (t[(t.CONNECT_ERROR = 4)] = "CONNECT_ERROR"), + (t[(t.BINARY_EVENT = 5)] = "BINARY_EVENT"), + (t[(t.BINARY_ACK = 6)] = "BINARY_ACK") + })(Bt || (Bt = {})) + var Nt = (function () { + function t(t) { + this.replacer = t + } + var n = t.prototype + return ( + (n.encode = function (t) { + return (t.type !== Bt.EVENT && t.type !== Bt.ACK) || !kt(t) + ? [this.encodeAsString(t)] + : this.encodeAsBinary({ + type: t.type === Bt.EVENT ? Bt.BINARY_EVENT : Bt.BINARY_ACK, + nsp: t.nsp, + data: t.data, + id: t.id, + }) + }), + (n.encodeAsString = function (t) { + var n = "" + t.type + return ( + (t.type !== Bt.BINARY_EVENT && t.type !== Bt.BINARY_ACK) || (n += t.attachments + "-"), + t.nsp && "/" !== t.nsp && (n += t.nsp + ","), + null != t.id && (n += t.id), + null != t.data && (n += JSON.stringify(t.data, this.replacer)), + n + ) + }), + (n.encodeAsBinary = function (t) { + var n = At(t), + i = this.encodeAsString(n.packet), + r = n.buffers + return r.unshift(i), r + }), + t + ) + })(), + Ct = (function (t) { + function n(n) { + var i + return ((i = t.call(this) || this).reviver = n), i + } + s(n, t) + var i = n.prototype + return ( + (i.add = function (n) { + var i + if ("string" == typeof n) { + if (this.reconstructor) throw new Error("got plaintext data when reconstructing a packet") + var r = (i = this.decodeString(n)).type === Bt.BINARY_EVENT + r || i.type === Bt.BINARY_ACK + ? ((i.type = r ? Bt.EVENT : Bt.ACK), + (this.reconstructor = new Tt(i)), + 0 === i.attachments && t.prototype.emitReserved.call(this, "decoded", i)) + : t.prototype.emitReserved.call(this, "decoded", i) + } else { + if (!mt(n) && !n.base64) throw new Error("Unknown type: " + n) + if (!this.reconstructor) throw new Error("got binary data when not reconstructing a packet") + ;(i = this.reconstructor.takeBinaryData(n)) && + ((this.reconstructor = null), t.prototype.emitReserved.call(this, "decoded", i)) + } + }), + (i.decodeString = function (t) { + var i = 0, + r = { type: Number(t.charAt(0)) } + if (void 0 === Bt[r.type]) throw new Error("unknown packet type " + r.type) + if (r.type === Bt.BINARY_EVENT || r.type === Bt.BINARY_ACK) { + for (var e = i + 1; "-" !== t.charAt(++i) && i != t.length; ); + var o = t.substring(e, i) + if (o != Number(o) || "-" !== t.charAt(i)) throw new Error("Illegal attachments") + r.attachments = Number(o) + } + if ("/" === t.charAt(i + 1)) { + for (var s = i + 1; ++i; ) { + if ("," === t.charAt(i)) break + if (i === t.length) break + } + r.nsp = t.substring(s, i) + } else r.nsp = "/" + var u = t.charAt(i + 1) + if ("" !== u && Number(u) == u) { + for (var h = i + 1; ++i; ) { + var f = t.charAt(i) + if (null == f || Number(f) != f) { + --i + break + } + if (i === t.length) break + } + r.id = Number(t.substring(h, i + 1)) + } + if (t.charAt(++i)) { + var c = this.tryParse(t.substr(i)) + if (!n.isPayloadValid(r.type, c)) throw new Error("invalid payload") + r.data = c + } + return r + }), + (i.tryParse = function (t) { + try { + return JSON.parse(t, this.reviver) + } catch (t) { + return !1 + } + }), + (n.isPayloadValid = function (t, n) { + switch (t) { + case Bt.CONNECT: + return Mt(n) + case Bt.DISCONNECT: + return void 0 === n + case Bt.CONNECT_ERROR: + return "string" == typeof n || Mt(n) + case Bt.EVENT: + case Bt.BINARY_EVENT: + return ( + Array.isArray(n) && + ("number" == typeof n[0] || ("string" == typeof n[0] && -1 === St.indexOf(n[0]))) + ) + case Bt.ACK: + case Bt.BINARY_ACK: + return Array.isArray(n) + } + }), + (i.destroy = function () { + this.reconstructor && (this.reconstructor.finishedReconstruction(), (this.reconstructor = null)) + }), + n + ) + })(I), + Tt = (function () { + function t(t) { + ;(this.packet = t), (this.buffers = []), (this.reconPack = t) + } + var n = t.prototype + return ( + (n.takeBinaryData = function (t) { + if ((this.buffers.push(t), this.buffers.length === this.reconPack.attachments)) { + var n = Et(this.reconPack, this.buffers) + return this.finishedReconstruction(), n + } + return null + }), + (n.finishedReconstruction = function () { + ;(this.reconPack = null), (this.buffers = []) + }), + t + ) + })() + var Ut = + Number.isInteger || + function (t) { + return "number" == typeof t && isFinite(t) && Math.floor(t) === t + } + function Mt(t) { + return "[object Object]" === Object.prototype.toString.call(t) + } + var xt = Object.freeze({ + __proto__: null, + protocol: 5, + get PacketType() { + return Bt + }, + Encoder: Nt, + Decoder: Ct, + isPacketValid: function (t) { + return ( + "string" == typeof t.nsp && + (void 0 === (n = t.id) || Ut(n)) && + (function (t, n) { + switch (t) { + case Bt.CONNECT: + return void 0 === n || Mt(n) + case Bt.DISCONNECT: + return void 0 === n + case Bt.EVENT: + return ( + Array.isArray(n) && + ("number" == typeof n[0] || ("string" == typeof n[0] && -1 === St.indexOf(n[0]))) + ) + case Bt.ACK: + return Array.isArray(n) + case Bt.CONNECT_ERROR: + return "string" == typeof n || Mt(n) + default: + return !1 + } + })(t.type, t.data) + ) + var n + }, + }) + function It(t, n, i) { + return ( + t.on(n, i), + function () { + t.off(n, i) + } + ) + } + var Rt = Object.freeze({ + connect: 1, + connect_error: 1, + disconnect: 1, + disconnecting: 1, + newListener: 1, + removeListener: 1, + }), + Lt = (function (t) { + function n(n, i, r) { + var o + return ( + ((o = t.call(this) || this).connected = !1), + (o.recovered = !1), + (o.receiveBuffer = []), + (o.sendBuffer = []), + (o.it = []), + (o.rt = 0), + (o.ids = 0), + (o.acks = {}), + (o.flags = {}), + (o.io = n), + (o.nsp = i), + r && r.auth && (o.auth = r.auth), + (o.l = e({}, r)), + o.io.et && o.open(), + o + ) + } + s(n, t) + var o = n.prototype + return ( + (o.subEvents = function () { + if (!this.subs) { + var t = this.io + this.subs = [ + It(t, "open", this.onopen.bind(this)), + It(t, "packet", this.onpacket.bind(this)), + It(t, "error", this.onerror.bind(this)), + It(t, "close", this.onclose.bind(this)), + ] + } + }), + (o.connect = function () { + return ( + this.connected || + (this.subEvents(), this.io.ot || this.io.open(), "open" === this.io.st && this.onopen()), + this + ) + }), + (o.open = function () { + return this.connect() + }), + (o.send = function () { + for (var t = arguments.length, n = new Array(t), i = 0; i < t; i++) n[i] = arguments[i] + return n.unshift("message"), this.emit.apply(this, n), this + }), + (o.emit = function (t) { + var n, i, r + if (Rt.hasOwnProperty(t)) throw new Error('"' + t.toString() + '" is a reserved event name') + for (var e = arguments.length, o = new Array(e > 1 ? e - 1 : 0), s = 1; s < e; s++) + o[s - 1] = arguments[s] + if ((o.unshift(t), this.l.retries && !this.flags.fromQueue && !this.flags.volatile)) + return this.ut(o), this + var u = { type: Bt.EVENT, data: o, options: {} } + if (((u.options.compress = !1 !== this.flags.compress), "function" == typeof o[o.length - 1])) { + var h = this.ids++, + f = o.pop() + this.ht(h, f), (u.id = h) + } + var c = + null === (i = null === (n = this.io.engine) || void 0 === n ? void 0 : n.transport) || + void 0 === i + ? void 0 + : i.writable, + a = this.connected && !(null === (r = this.io.engine) || void 0 === r ? void 0 : r.W()) + return ( + (this.flags.volatile && !c) || + (a ? (this.notifyOutgoingListeners(u), this.packet(u)) : this.sendBuffer.push(u)), + (this.flags = {}), + this + ) + }), + (o.ht = function (t, n) { + var i, + r = this, + e = null !== (i = this.flags.timeout) && void 0 !== i ? i : this.l.ackTimeout + if (void 0 !== e) { + var o = this.io.setTimeoutFn(function () { + delete r.acks[t] + for (var i = 0; i < r.sendBuffer.length; i++) + r.sendBuffer[i].id === t && r.sendBuffer.splice(i, 1) + n.call(r, new Error("operation has timed out")) + }, e), + s = function () { + r.io.clearTimeoutFn(o) + for (var t = arguments.length, i = new Array(t), e = 0; e < t; e++) i[e] = arguments[e] + n.apply(r, i) + } + ;(s.withError = !0), (this.acks[t] = s) + } else this.acks[t] = n + }), + (o.emitWithAck = function (t) { + for (var n = this, i = arguments.length, r = new Array(i > 1 ? i - 1 : 0), e = 1; e < i; e++) + r[e - 1] = arguments[e] + return new Promise(function (i, e) { + var o = function (t, n) { + return t ? e(t) : i(n) + } + ;(o.withError = !0), r.push(o), n.emit.apply(n, [t].concat(r)) + }) + }), + (o.ut = function (t) { + var n, + i = this + "function" == typeof t[t.length - 1] && (n = t.pop()) + var r = { + id: this.rt++, + tryCount: 0, + pending: !1, + args: t, + flags: e({ fromQueue: !0 }, this.flags), + } + t.push(function (t) { + if ((i.it[0], null !== t)) r.tryCount > i.l.retries && (i.it.shift(), n && n(t)) + else if ((i.it.shift(), n)) { + for (var e = arguments.length, o = new Array(e > 1 ? e - 1 : 0), s = 1; s < e; s++) + o[s - 1] = arguments[s] + n.apply(void 0, [null].concat(o)) + } + return (r.pending = !1), i.ft() + }), + this.it.push(r), + this.ft() + }), + (o.ft = function () { + var t = arguments.length > 0 && void 0 !== arguments[0] && arguments[0] + if (this.connected && 0 !== this.it.length) { + var n = this.it[0] + ;(n.pending && !t) || + ((n.pending = !0), n.tryCount++, (this.flags = n.flags), this.emit.apply(this, n.args)) + } + }), + (o.packet = function (t) { + ;(t.nsp = this.nsp), this.io.ct(t) + }), + (o.onopen = function () { + var t = this + "function" == typeof this.auth + ? this.auth(function (n) { + t.vt(n) + }) + : this.vt(this.auth) + }), + (o.vt = function (t) { + this.packet({ type: Bt.CONNECT, data: this.lt ? e({ pid: this.lt, offset: this.dt }, t) : t }) + }), + (o.onerror = function (t) { + this.connected || this.emitReserved("connect_error", t) + }), + (o.onclose = function (t, n) { + ;(this.connected = !1), delete this.id, this.emitReserved("disconnect", t, n), this.yt() + }), + (o.yt = function () { + var t = this + Object.keys(this.acks).forEach(function (n) { + if ( + !t.sendBuffer.some(function (t) { + return String(t.id) === n + }) + ) { + var i = t.acks[n] + delete t.acks[n], i.withError && i.call(t, new Error("socket has been disconnected")) + } + }) + }), + (o.onpacket = function (t) { + if (t.nsp === this.nsp) + switch (t.type) { + case Bt.CONNECT: + t.data && t.data.sid + ? this.onconnect(t.data.sid, t.data.pid) + : this.emitReserved( + "connect_error", + new Error( + "It seems you are trying to reach a Socket.IO server in v2.x with a v3.x client, but they are not compatible (more information here: https://socket.io/docs/v3/migrating-from-2-x-to-3-0/)", + ), + ) + break + case Bt.EVENT: + case Bt.BINARY_EVENT: + this.onevent(t) + break + case Bt.ACK: + case Bt.BINARY_ACK: + this.onack(t) + break + case Bt.DISCONNECT: + this.ondisconnect() + break + case Bt.CONNECT_ERROR: + this.destroy() + var n = new Error(t.data.message) + ;(n.data = t.data.data), this.emitReserved("connect_error", n) + } + }), + (o.onevent = function (t) { + var n = t.data || [] + null != t.id && n.push(this.ack(t.id)), + this.connected ? this.emitEvent(n) : this.receiveBuffer.push(Object.freeze(n)) + }), + (o.emitEvent = function (n) { + if (this.bt && this.bt.length) { + var i, + e = r(this.bt.slice()) + try { + for (e.s(); !(i = e.n()).done; ) { + i.value.apply(this, n) + } + } catch (t) { + e.e(t) + } finally { + e.f() + } + } + t.prototype.emit.apply(this, n), + this.lt && n.length && "string" == typeof n[n.length - 1] && (this.dt = n[n.length - 1]) + }), + (o.ack = function (t) { + var n = this, + i = !1 + return function () { + if (!i) { + i = !0 + for (var r = arguments.length, e = new Array(r), o = 0; o < r; o++) e[o] = arguments[o] + n.packet({ type: Bt.ACK, id: t, data: e }) + } + } + }), + (o.onack = function (t) { + var n = this.acks[t.id] + "function" == typeof n && + (delete this.acks[t.id], n.withError && t.data.unshift(null), n.apply(this, t.data)) + }), + (o.onconnect = function (t, n) { + ;(this.id = t), + (this.recovered = n && this.lt === n), + (this.lt = n), + (this.connected = !0), + this.emitBuffered(), + this.ft(!0), + this.emitReserved("connect") + }), + (o.emitBuffered = function () { + var t = this + this.receiveBuffer.forEach(function (n) { + return t.emitEvent(n) + }), + (this.receiveBuffer = []), + this.sendBuffer.forEach(function (n) { + t.notifyOutgoingListeners(n), t.packet(n) + }), + (this.sendBuffer = []) + }), + (o.ondisconnect = function () { + this.destroy(), this.onclose("io server disconnect") + }), + (o.destroy = function () { + this.subs && + (this.subs.forEach(function (t) { + return t() + }), + (this.subs = void 0)), + this.io.wt(this) + }), + (o.disconnect = function () { + return ( + this.connected && this.packet({ type: Bt.DISCONNECT }), + this.destroy(), + this.connected && this.onclose("io client disconnect"), + this + ) + }), + (o.close = function () { + return this.disconnect() + }), + (o.compress = function (t) { + return (this.flags.compress = t), this + }), + (o.timeout = function (t) { + return (this.flags.timeout = t), this + }), + (o.onAny = function (t) { + return (this.bt = this.bt || []), this.bt.push(t), this + }), + (o.prependAny = function (t) { + return (this.bt = this.bt || []), this.bt.unshift(t), this + }), + (o.offAny = function (t) { + if (!this.bt) return this + if (t) { + for (var n = this.bt, i = 0; i < n.length; i++) if (t === n[i]) return n.splice(i, 1), this + } else this.bt = [] + return this + }), + (o.listenersAny = function () { + return this.bt || [] + }), + (o.onAnyOutgoing = function (t) { + return (this.gt = this.gt || []), this.gt.push(t), this + }), + (o.prependAnyOutgoing = function (t) { + return (this.gt = this.gt || []), this.gt.unshift(t), this + }), + (o.offAnyOutgoing = function (t) { + if (!this.gt) return this + if (t) { + for (var n = this.gt, i = 0; i < n.length; i++) if (t === n[i]) return n.splice(i, 1), this + } else this.gt = [] + return this + }), + (o.listenersAnyOutgoing = function () { + return this.gt || [] + }), + (o.notifyOutgoingListeners = function (t) { + if (this.gt && this.gt.length) { + var n, + i = r(this.gt.slice()) + try { + for (i.s(); !(n = i.n()).done; ) { + n.value.apply(this, t.data) + } + } catch (t) { + i.e(t) + } finally { + i.f() + } + } + }), + i(n, [ + { + key: "disconnected", + get: function () { + return !this.connected + }, + }, + { + key: "active", + get: function () { + return !!this.subs + }, + }, + { + key: "volatile", + get: function () { + return (this.flags.volatile = !0), this + }, + }, + ]) + ) + })(I) + function _t(t) { + ;(t = t || {}), + (this.ms = t.min || 100), + (this.max = t.max || 1e4), + (this.factor = t.factor || 2), + (this.jitter = t.jitter > 0 && t.jitter <= 1 ? t.jitter : 0), + (this.attempts = 0) + } + ;(_t.prototype.duration = function () { + var t = this.ms * Math.pow(this.factor, this.attempts++) + if (this.jitter) { + var n = Math.random(), + i = Math.floor(n * this.jitter * t) + t = 1 & Math.floor(10 * n) ? t + i : t - i + } + return 0 | Math.min(t, this.max) + }), + (_t.prototype.reset = function () { + this.attempts = 0 + }), + (_t.prototype.setMin = function (t) { + this.ms = t + }), + (_t.prototype.setMax = function (t) { + this.max = t + }), + (_t.prototype.setJitter = function (t) { + this.jitter = t + }) + var Dt = (function (t) { + function n(n, i) { + var r, e + ;((r = t.call(this) || this).nsps = {}), + (r.subs = []), + n && "object" === c(n) && ((i = n), (n = void 0)), + ((i = i || {}).path = i.path || "/socket.io"), + (r.opts = i), + $(r, i), + r.reconnection(!1 !== i.reconnection), + r.reconnectionAttempts(i.reconnectionAttempts || 1 / 0), + r.reconnectionDelay(i.reconnectionDelay || 1e3), + r.reconnectionDelayMax(i.reconnectionDelayMax || 5e3), + r.randomizationFactor(null !== (e = i.randomizationFactor) && void 0 !== e ? e : 0.5), + (r.backoff = new _t({ + min: r.reconnectionDelay(), + max: r.reconnectionDelayMax(), + jitter: r.randomizationFactor(), + })), + r.timeout(null == i.timeout ? 2e4 : i.timeout), + (r.st = "closed"), + (r.uri = n) + var o = i.parser || xt + return ( + (r.encoder = new o.Encoder()), + (r.decoder = new o.Decoder()), + (r.et = !1 !== i.autoConnect), + r.et && r.open(), + r + ) + } + s(n, t) + var i = n.prototype + return ( + (i.reconnection = function (t) { + return arguments.length ? ((this.kt = !!t), t || (this.skipReconnect = !0), this) : this.kt + }), + (i.reconnectionAttempts = function (t) { + return void 0 === t ? this.At : ((this.At = t), this) + }), + (i.reconnectionDelay = function (t) { + var n + return void 0 === t + ? this.jt + : ((this.jt = t), null === (n = this.backoff) || void 0 === n || n.setMin(t), this) + }), + (i.randomizationFactor = function (t) { + var n + return void 0 === t + ? this.Et + : ((this.Et = t), null === (n = this.backoff) || void 0 === n || n.setJitter(t), this) + }), + (i.reconnectionDelayMax = function (t) { + var n + return void 0 === t + ? this.Ot + : ((this.Ot = t), null === (n = this.backoff) || void 0 === n || n.setMax(t), this) + }), + (i.timeout = function (t) { + return arguments.length ? ((this.Bt = t), this) : this.Bt + }), + (i.maybeReconnectOnOpen = function () { + !this.ot && this.kt && 0 === this.backoff.attempts && this.reconnect() + }), + (i.open = function (t) { + var n = this + if (~this.st.indexOf("open")) return this + this.engine = new pt(this.uri, this.opts) + var i = this.engine, + r = this + ;(this.st = "opening"), (this.skipReconnect = !1) + var e = It(i, "open", function () { + r.onopen(), t && t() + }), + o = function (i) { + n.cleanup(), + (n.st = "closed"), + n.emitReserved("error", i), + t ? t(i) : n.maybeReconnectOnOpen() + }, + s = It(i, "error", o) + if (!1 !== this.Bt) { + var u = this.Bt, + h = this.setTimeoutFn(function () { + e(), o(new Error("timeout")), i.close() + }, u) + this.opts.autoUnref && h.unref(), + this.subs.push(function () { + n.clearTimeoutFn(h) + }) + } + return this.subs.push(e), this.subs.push(s), this + }), + (i.connect = function (t) { + return this.open(t) + }), + (i.onopen = function () { + this.cleanup(), (this.st = "open"), this.emitReserved("open") + var t = this.engine + this.subs.push( + It(t, "ping", this.onping.bind(this)), + It(t, "data", this.ondata.bind(this)), + It(t, "error", this.onerror.bind(this)), + It(t, "close", this.onclose.bind(this)), + It(this.decoder, "decoded", this.ondecoded.bind(this)), + ) + }), + (i.onping = function () { + this.emitReserved("ping") + }), + (i.ondata = function (t) { + try { + this.decoder.add(t) + } catch (t) { + this.onclose("parse error", t) + } + }), + (i.ondecoded = function (t) { + var n = this + R(function () { + n.emitReserved("packet", t) + }, this.setTimeoutFn) + }), + (i.onerror = function (t) { + this.emitReserved("error", t) + }), + (i.socket = function (t, n) { + var i = this.nsps[t] + return i ? this.et && !i.active && i.connect() : ((i = new Lt(this, t, n)), (this.nsps[t] = i)), i + }), + (i.wt = function (t) { + for (var n = 0, i = Object.keys(this.nsps); n < i.length; n++) { + var r = i[n] + if (this.nsps[r].active) return + } + this.St() + }), + (i.ct = function (t) { + for (var n = this.encoder.encode(t), i = 0; i < n.length; i++) this.engine.write(n[i], t.options) + }), + (i.cleanup = function () { + this.subs.forEach(function (t) { + return t() + }), + (this.subs.length = 0), + this.decoder.destroy() + }), + (i.St = function () { + ;(this.skipReconnect = !0), (this.ot = !1), this.onclose("forced close") + }), + (i.disconnect = function () { + return this.St() + }), + (i.onclose = function (t, n) { + var i + this.cleanup(), + null === (i = this.engine) || void 0 === i || i.close(), + this.backoff.reset(), + (this.st = "closed"), + this.emitReserved("close", t, n), + this.kt && !this.skipReconnect && this.reconnect() + }), + (i.reconnect = function () { + var t = this + if (this.ot || this.skipReconnect) return this + var n = this + if (this.backoff.attempts >= this.At) + this.backoff.reset(), this.emitReserved("reconnect_failed"), (this.ot = !1) + else { + var i = this.backoff.duration() + this.ot = !0 + var r = this.setTimeoutFn(function () { + n.skipReconnect || + (t.emitReserved("reconnect_attempt", n.backoff.attempts), + n.skipReconnect || + n.open(function (i) { + i + ? ((n.ot = !1), n.reconnect(), t.emitReserved("reconnect_error", i)) + : n.onreconnect() + })) + }, i) + this.opts.autoUnref && r.unref(), + this.subs.push(function () { + t.clearTimeoutFn(r) + }) + } + }), + (i.onreconnect = function () { + var t = this.backoff.attempts + ;(this.ot = !1), this.backoff.reset(), this.emitReserved("reconnect", t) + }), + n + ) + })(I), + Pt = {} + function $t(t, n) { + "object" === c(t) && ((n = t), (t = void 0)) + var i, + r = (function (t) { + var n = arguments.length > 1 && void 0 !== arguments[1] ? arguments[1] : "", + i = arguments.length > 2 ? arguments[2] : void 0, + r = t + ;(i = i || ("undefined" != typeof location && location)), + null == t && (t = i.protocol + "//" + i.host), + "string" == typeof t && + ("/" === t.charAt(0) && (t = "/" === t.charAt(1) ? i.protocol + t : i.host + t), + /^(https?|wss?):\/\//.test(t) || (t = void 0 !== i ? i.protocol + "//" + t : "https://" + t), + (r = ft(t))), + r.port || + (/^(http|ws)$/.test(r.protocol) + ? (r.port = "80") + : /^(http|ws)s$/.test(r.protocol) && (r.port = "443")), + (r.path = r.path || "/") + var e = -1 !== r.host.indexOf(":") ? "[" + r.host + "]" : r.host + return ( + (r.id = r.protocol + "://" + e + ":" + r.port + n), + (r.href = r.protocol + "://" + e + (i && i.port === r.port ? "" : ":" + r.port)), + r + ) + })(t, (n = n || {}).path || "/socket.io"), + e = r.source, + o = r.id, + s = r.path, + u = Pt[o] && s in Pt[o].nsps + return ( + n.forceNew || n["force new connection"] || !1 === n.multiplex || u + ? (i = new Dt(e, n)) + : (Pt[o] || (Pt[o] = new Dt(e, n)), (i = Pt[o])), + r.query && !n.query && (n.query = r.queryKey), + i.socket(r.path, n) + ) + } + return e($t, { Manager: Dt, Socket: Lt, io: $t, connect: $t }), $t +}) +//# sourceMappingURL=socket.io.min.js.map diff --git a/self-hosted-cloudapi/src/web/templates/base.html b/self-hosted-cloudapi/src/web/templates/base.html new file mode 100644 index 0000000000..2b784b27d3 --- /dev/null +++ b/self-hosted-cloudapi/src/web/templates/base.html @@ -0,0 +1,25 @@ + + + + + + {% block title %}Tumble Code{% endblock %} + + + +
    + Tumble Code Cloud +
    + {% if user %} + {{ user.name }} + Sign out + {% else %} + Sign in + {% endif %} +
    +
    + {% block content %}{% endblock %} +
    + {% block scripts %}{% endblock %} + + diff --git a/self-hosted-cloudapi/src/web/templates/not_found.html b/self-hosted-cloudapi/src/web/templates/not_found.html new file mode 100644 index 0000000000..b88ad3a7f7 --- /dev/null +++ b/self-hosted-cloudapi/src/web/templates/not_found.html @@ -0,0 +1,9 @@ +{% extends "base.html" %} +{% block title %}Not found · Tumble Code{% endblock %} +{% block content %} +
    +

    Task not found

    +

    This task does not exist, or you don't have access to it.

    +

    Back to your tasks

    +
    +{% endblock %} diff --git a/self-hosted-cloudapi/src/web/templates/task_detail.html b/self-hosted-cloudapi/src/web/templates/task_detail.html new file mode 100644 index 0000000000..f33b219c5c --- /dev/null +++ b/self-hosted-cloudapi/src/web/templates/task_detail.html @@ -0,0 +1,75 @@ +{% extends "base.html" %} +{% block title %}{{ title }} · Tumble Code{% endblock %} +{% block content %} +
    + ← All tasks +
    +

    {{ title }}

    + {% if can_delete %} +
    + +
    + {% endif %} +
    + {% if share_url and not live %}{% endif %} +
    + +{% if live %} +
    + Connecting… + + mode + tokens in / out + context + cost +
    +{% endif %} + +
    +
    Rendering conversation…
    +
    + +{% if live %} +
    +
    + + + + + + + + + + +
    +
    + +
    + + + +
    +
    +
    + +{% endif %} + + +{% endblock %} +{% block scripts %} + + + +{% if live %} + + +{% endif %} +{% endblock %} diff --git a/self-hosted-cloudapi/src/web/templates/tasks_list.html b/self-hosted-cloudapi/src/web/templates/tasks_list.html new file mode 100644 index 0000000000..a161f5106d --- /dev/null +++ b/self-hosted-cloudapi/src/web/templates/tasks_list.html @@ -0,0 +1,29 @@ +{% extends "base.html" %} +{% block title %}Your tasks · Tumble Code{% endblock %} +{% block content %} +

    Your tasks

    +{% if tasks %} + +{% else %} +
    +

    No shared tasks yet.

    +

    In VS Code, open a task and click Share to make it appear here.

    +
    +{% endif %} +{% endblock %} diff --git a/self-hosted-cloudapi/tests/test_bridge.py b/self-hosted-cloudapi/tests/test_bridge.py new file mode 100644 index 0000000000..6be91537c5 --- /dev/null +++ b/self-hosted-cloudapi/tests/test_bridge.py @@ -0,0 +1,446 @@ +"""Tests for the live remote-control bridge (socket.io relay). + +python-socketio has no in-process ASGI test client (no httpx-style transport), +so these exercise the relay's logic at the unit the rest of the suite uses: +the pure `ConnectionRegistry`, the handshake auth helpers, and the event/command +handlers called directly with `sio.emit`/`enter_room` stubbed and +`async_session_factory` pointed at the in-memory test engine. + +The four guarantees under test (from the plan's Verification section): + (a) an extension handshake with a valid JWT registers an instance; + (b) a browser may `task:join` only a task it owns (foreign task rejected); + (c) a `task:command` is relayed only to that user's own extension socket; + (d) an extension Message event upserts a TaskMessage so history stays current. +""" + +import json +from unittest.mock import AsyncMock + +import pytest +from sqlalchemy import select, func + +from src.auth.jwt_issuer import issue_session_token +from src.auth.web_session import _serializer +from src.models.user import User, Session +from src.models.task import Task, TaskMessage +from src.realtime import sio as sio_module +from src.realtime.hub import ConnectionRegistry, registry +from src.realtime.sio import ( + _user_id_from_token, + _cookie_from_environ, + EVT_MESSAGE, + EVT_INSTANCE_STATE, + TASK_RELAYED_EVENT, + TASK_RELAYED_COMMAND, +) + + +# --- fixtures -------------------------------------------------------------- + + +@pytest.fixture(autouse=True) +def _clean_registry(): + """The relay registry is a process singleton; reset it around each test.""" + registry._meta.clear() + registry._ext_sid_by_user.clear() + registry._instance_by_user.clear() + yield + registry._meta.clear() + registry._ext_sid_by_user.clear() + registry._instance_by_user.clear() + + +@pytest.fixture +def patch_session_factory(monkeypatch, session_factory): + """Point the handlers' DB access at the in-memory test engine.""" + monkeypatch.setattr(sio_module, "async_session_factory", session_factory) + return session_factory + + +@pytest.fixture +def stub_emit(monkeypatch): + """Replace the socket.io I/O methods so relays are captured, not sent.""" + emit = AsyncMock() + enter = AsyncMock() + leave = AsyncMock() + monkeypatch.setattr(sio_module.sio, "emit", emit) + monkeypatch.setattr(sio_module.sio, "enter_room", enter) + monkeypatch.setattr(sio_module.sio, "leave_room", leave) + return emit + + +async def _seed_user(db, user_id="user_test", email="t@example.com"): + db.add(User(id=user_id, authentik_id=f"ak_{user_id}", email=email, + first_name="Test", last_name="User")) + await db.commit() + + +async def _seed_session(db, user_id, session_id="sess_web"): + db.add(Session(id=session_id, user_id=user_id, is_active=True)) + await db.commit() + return session_id + + +def _signed_cookie(session_id, user_id): + return _serializer.dumps({"sid": session_id, "uid": user_id}) + + +# --- pure registry --------------------------------------------------------- + + +def test_registry_pairs_browser_to_extension_and_clears_on_detach(): + reg = ConnectionRegistry() + reg.attach("ext1", "extension", "u1") + reg.register_extension("ext1", "u1", {"instanceId": "i1"}) + reg.attach("br1", "browser", "u1") + + assert reg.extension_sid("u1") == "ext1" + assert reg.has_extension("u1") is True + assert reg.instance("u1")["instanceId"] == "i1" + assert reg.meta("br1")["role"] == "browser" + + # Detaching the extension clears the pairing; the browser is unaffected. + reg.detach("ext1") + assert reg.extension_sid("u1") is None + assert reg.has_extension("u1") is False + assert reg.instance("u1") is None + assert reg.meta("br1")["role"] == "browser" + + +def test_registry_newest_extension_instance_wins(): + reg = ConnectionRegistry() + reg.attach("ext_old", "extension", "u1") + reg.register_extension("ext_old", "u1") + reg.attach("ext_new", "extension", "u1") + reg.register_extension("ext_new", "u1") + assert reg.extension_sid("u1") == "ext_new" + + # A stale socket detaching must not clear the live pairing. + reg.detach("ext_old") + assert reg.extension_sid("u1") == "ext_new" + + +def test_registry_update_instance_state_merges(): + reg = ConnectionRegistry() + reg.register_extension("ext1", "u1", {"instanceId": "i1"}) + reg.update_instance_state("u1", {"contextTokens": 1234, "isRunning": True}) + inst = reg.instance("u1") + assert inst["instanceId"] == "i1" + assert inst["contextTokens"] == 1234 + assert inst["isRunning"] is True + + +# --- handshake auth helpers ------------------------------------------------ + + +def test_user_id_from_token_round_trips_jwt(): + token = issue_session_token("user_abc", expires_in=300) + assert _user_id_from_token(token) == "user_abc" + + +def test_user_id_from_token_rejects_garbage(): + assert _user_id_from_token(None) is None + assert _user_id_from_token("") is None + assert _user_id_from_token("not-a-real-token") is None + + +def test_cookie_from_environ_extracts_session_cookie(): + environ = {"HTTP_COOKIE": "foo=bar; tumble_session=abc123; baz=qux"} + assert _cookie_from_environ(environ) == "abc123" + assert _cookie_from_environ({}) is None + assert _cookie_from_environ({"HTTP_COOKIE": "other=1"}) is None + + +# --- (a) extension handshake registers an instance ------------------------- + + +async def test_connect_extension_with_valid_jwt_attaches_and_registers(patch_session_factory): + token = issue_session_token("user_ext", expires_in=300) + ok = await sio_module.connect("extsid", {}, {"token": token}) + assert ok is True + assert registry.meta("extsid") == {"role": "extension", "user_id": "user_ext"} + + res = await sio_module.on_extension_register("extsid", {"instanceId": "win-1"}) + assert res == {"success": True} + assert registry.extension_sid("user_ext") == "extsid" + assert registry.instance("user_ext")["instanceId"] == "win-1" + + +async def test_connect_extension_with_bad_token_is_rejected(patch_session_factory): + assert await sio_module.connect("extsid", {}, {"token": "garbage"}) is False + assert registry.meta("extsid") is None + + +# --- browser handshake via cookie ------------------------------------------ + + +async def test_connect_browser_with_valid_cookie(patch_session_factory, db_session): + await _seed_user(db_session, "user_web") + sid_val = await _seed_session(db_session, "user_web") + cookie = _signed_cookie(sid_val, "user_web") + environ = {"HTTP_COOKIE": f"tumble_session={cookie}"} + + ok = await sio_module.connect("brsid", environ, None) + assert ok is True + assert registry.meta("brsid") == {"role": "browser", "user_id": "user_web"} + + +async def test_connect_browser_without_cookie_is_rejected(patch_session_factory): + assert await sio_module.connect("brsid", {}, None) is False + assert registry.meta("brsid") is None + + +# --- (b) task:join is ownership-checked ------------------------------------ + + +async def test_task_join_only_owned_task(patch_session_factory, db_session, stub_emit): + await _seed_user(db_session, "owner") + await _seed_user(db_session, "stranger", email="s@example.com") + db_session.add(Task(id="task-own", user_id="owner")) + db_session.add(Task(id="task-foreign", user_id="stranger")) + await db_session.commit() + + registry.attach("brsid", "browser", "owner") + + ok = await sio_module.on_task_join("brsid", {"taskId": "task-own"}) + assert ok["success"] is True + assert ok["taskId"] == "task-own" + + foreign = await sio_module.on_task_join("brsid", {"taskId": "task-foreign"}) + assert foreign == {"success": False, "error": "forbidden"} + + missing = await sio_module.on_task_join("brsid", {"taskId": "nope"}) + assert missing == {"success": False, "error": "forbidden"} + + +# --- (c) task:command relayed only to the owner's extension ---------------- + + +async def test_task_command_relayed_only_to_owner_extension( + patch_session_factory, db_session, stub_emit +): + await _seed_user(db_session, "owner") + db_session.add(Task(id="task-own", user_id="owner")) + await db_session.commit() + + # Owner has a browser AND a registered extension; a different user also has one. + registry.attach("br_owner", "browser", "owner") + registry.attach("ext_owner", "extension", "owner") + registry.register_extension("ext_owner", "owner") + registry.attach("ext_other", "extension", "stranger") + registry.register_extension("ext_other", "stranger") + + cmd = {"taskId": "task-own", "type": "stop_task", "timestamp": 1} + res = await sio_module.on_task_command("br_owner", cmd) + assert res == {"success": True} + + # Relayed exactly once, only to the owner's extension socket. + stub_emit.assert_awaited_once_with(TASK_RELAYED_COMMAND, cmd, to="ext_owner") + + +async def test_task_command_on_foreign_task_is_forbidden( + patch_session_factory, db_session, stub_emit +): + await _seed_user(db_session, "owner") + await _seed_user(db_session, "stranger", email="s@example.com") + db_session.add(Task(id="task-foreign", user_id="stranger")) + await db_session.commit() + + registry.attach("br_owner", "browser", "owner") + registry.attach("ext_owner", "extension", "owner") + registry.register_extension("ext_owner", "owner") + + res = await sio_module.on_task_command( + "br_owner", {"taskId": "task-foreign", "type": "stop_task"} + ) + assert res == {"success": False, "error": "forbidden"} + stub_emit.assert_not_awaited() + + +async def test_task_command_when_extension_offline( + patch_session_factory, db_session, stub_emit +): + await _seed_user(db_session, "owner") + db_session.add(Task(id="task-own", user_id="owner")) + await db_session.commit() + + registry.attach("br_owner", "browser", "owner") # no extension registered + + res = await sio_module.on_task_command( + "br_owner", {"taskId": "task-own", "type": "stop_task"} + ) + assert res == {"success": False, "error": "extension offline"} + stub_emit.assert_not_awaited() + + +# --- (d) extension Message event relays + persists ------------------------- + + +async def test_task_event_message_relays_and_upserts( + patch_session_factory, db_session, session_factory, stub_emit +): + await _seed_user(db_session, "owner") + db_session.add(Task(id="task-own", user_id="owner")) + await db_session.commit() + + registry.attach("ext_owner", "extension", "owner") + registry.register_extension("ext_owner", "owner") + + message = {"ts": 42, "type": "say", "say": "text", "text": "hello from the task"} + event = {"taskId": "task-own", "type": EVT_MESSAGE, "message": message} + await sio_module.on_task_event("ext_owner", event) + + # Relayed to the task room for any watching browser... + stub_emit.assert_awaited_once_with(TASK_RELAYED_EVENT, event, room="task:task-own") + + # ...and persisted so /app/tasks/{id} history stays current. + async with session_factory() as s: + rows = ( + await s.execute( + select(TaskMessage).where(TaskMessage.task_id == "task-own") + ) + ).scalars().all() + assert len(rows) == 1 + assert rows[0].message_ts == 42 + assert "hello from the task" in rows[0].message_data + + +async def test_task_event_message_upsert_is_idempotent_by_ts( + patch_session_factory, db_session, session_factory, stub_emit +): + """A streaming message arrives partial→final under one ts; it must update the + same row, not append duplicates.""" + await _seed_user(db_session, "owner") + db_session.add(Task(id="task-own", user_id="owner")) + await db_session.commit() + registry.attach("ext_owner", "extension", "owner") + + base = {"taskId": "task-own", "type": EVT_MESSAGE} + await sio_module.on_task_event( + "ext_owner", {**base, "message": {"ts": 7, "type": "say", "say": "text", + "text": "partial", "partial": True}} + ) + await sio_module.on_task_event( + "ext_owner", {**base, "message": {"ts": 7, "type": "say", "say": "text", + "text": "partial then final"}} + ) + + async with session_factory() as s: + n = ( + await s.execute( + select(func.count(TaskMessage.id)).where(TaskMessage.task_id == "task-own") + ) + ).scalar_one() + assert n == 1 + row = ( + await s.execute(select(TaskMessage).where(TaskMessage.task_id == "task-own")) + ).scalar_one() + assert "final" in row.message_data + + +async def test_task_event_reasoning_stream_collapses_and_finalizes( + patch_session_factory, db_session, session_factory, stub_emit +): + """Reproduces the stuck-spinner bug: many rapid `partial:true` reasoning + chunks followed by a `partial:false` finalize must yield exactly one row, + stored with partial=false (so the web view never spins forever).""" + import json + + await _seed_user(db_session, "owner") + db_session.add(Task(id="task-own", user_id="owner")) + await db_session.commit() + registry.attach("ext_owner", "extension", "owner") + + base = {"taskId": "task-own", "type": EVT_MESSAGE} + for i in range(1, 6): + await sio_module.on_task_event( + "ext_owner", + {**base, "message": {"ts": 42, "type": "say", "say": "reasoning", + "text": "thinking " * i, "partial": True}}, + ) + # Finalizer (TaskStreamProcessor sets partial=false on the reasoning row). + await sio_module.on_task_event( + "ext_owner", + {**base, "message": {"ts": 42, "type": "say", "say": "reasoning", + "text": "thinking thinking thinking thinking thinking", + "partial": False}}, + ) + + async with session_factory() as s: + rows = ( + await s.execute(select(TaskMessage).where(TaskMessage.task_id == "task-own")) + ).scalars().all() + assert len(rows) == 1 + assert json.loads(rows[0].message_data).get("partial") is False + + +async def test_task_event_upsert_never_regresses_to_shorter_partial( + patch_session_factory, db_session, session_factory, stub_emit +): + """The concurrent per-event upserts for one ts serialize on the unique-index + row lock, so the *last to commit* — non-deterministically an early, short + partial — would otherwise win and freeze the row at truncated text. The + monotonic length guard must reject any payload shorter than what is stored, + so the full/finalized text is preserved regardless of commit order.""" + import json + + await _seed_user(db_session, "owner") + db_session.add(Task(id="task-own", user_id="owner")) + await db_session.commit() + registry.attach("ext_owner", "extension", "owner") + + base = {"taskId": "task-own", "type": EVT_MESSAGE} + full = "The user says they need a complete summary of every recent change." + # Final/full payload commits first... + await sio_module.on_task_event( + "ext_owner", + {**base, "message": {"ts": 99, "type": "say", "say": "reasoning", + "text": full, "partial": False}}, + ) + # ...then an early, short partial for the same ts arrives late (the race). + await sio_module.on_task_event( + "ext_owner", + {**base, "message": {"ts": 99, "type": "say", "say": "reasoning", + "text": "The user says", "partial": True}}, + ) + + async with session_factory() as s: + rows = ( + await s.execute(select(TaskMessage).where(TaskMessage.task_id == "task-own")) + ).scalars().all() + assert len(rows) == 1 + stored = json.loads(rows[0].message_data) + assert stored["text"] == full + assert stored.get("partial") is False + + +async def test_task_event_instance_state_updates_registry( + patch_session_factory, db_session, stub_emit +): + await _seed_user(db_session, "owner") + db_session.add(Task(id="task-own", user_id="owner")) + await db_session.commit() + registry.attach("ext_owner", "extension", "owner") + registry.register_extension("ext_owner", "owner") + + event = { + "taskId": "task-own", + "type": EVT_INSTANCE_STATE, + "isRunning": True, + "contextTokens": 5000, + "contextWindow": 200000, + } + await sio_module.on_task_event("ext_owner", event) + + stub_emit.assert_awaited_once_with(TASK_RELAYED_EVENT, event, room="task:task-own") + inst = registry.instance("owner") + assert inst["isRunning"] is True + assert inst["contextTokens"] == 5000 + assert inst["contextWindow"] == 200000 + + +async def test_task_event_from_non_extension_is_ignored(stub_emit): + registry.attach("br1", "browser", "owner") + await sio_module.on_task_event("br1", {"taskId": "t", "type": EVT_MESSAGE, + "message": {"ts": 1}}) + stub_emit.assert_not_awaited() diff --git a/self-hosted-cloudapi/tests/test_web_and_share.py b/self-hosted-cloudapi/tests/test_web_and_share.py new file mode 100644 index 0000000000..d00ac2d69b --- /dev/null +++ b/self-hosted-cloudapi/tests/test_web_and_share.py @@ -0,0 +1,573 @@ +"""Tests for the share/backfill pipeline fixes and the web task viewer. + +Covers the three backend blockers that were preventing tasks from ever +persisting, plus the new server-rendered web routes: + +- Blocker B: POST /api/extension/share returns 404 (not 200) for an unknown + task, so the extension knows to backfill and retry. +- Blocker C: POST /api/events/backfill creates the parent Task row and replaces + the message set idempotently on re-share. +- Web: /app requires a session; task detail enforces ownership; /shared honours + the share visibility. +""" + +import json + +import pytest +from sqlalchemy import select, func + +from src.dependencies import get_current_user +from src.auth.web_session import get_web_user_optional, WebUser +from src.models.user import User +from src.models.task import Task, TaskMessage, TaskShare +from src.services.settings_service import get_extension_settings + + +# --- helpers --------------------------------------------------------------- + + +async def _seed_user(db_session, user_id="user_test", email="t@example.com"): + user = User( + id=user_id, + authentik_id=f"ak_{user_id}", + email=email, + first_name="Test", + last_name="User", + ) + db_session.add(user) + await db_session.commit() + return user + + +def _override_current_user(client_app, user_id="user_test"): + client_app.dependency_overrides[get_current_user] = lambda: { + "user_id": user_id, + "org_id": None, + } + + +def _override_web_user(client_app, user_id="user_test", email="t@example.com"): + web_user: WebUser = { + "user_id": user_id, + "session_id": "sess_test", + "email": email, + "name": "Test User", + "image_url": None, + } + client_app.dependency_overrides[get_web_user_optional] = lambda: web_user + + +def _msgs(): + return [ + {"ts": 1, "type": "say", "say": "text", "text": "Build me a feature"}, + {"ts": 2, "type": "say", "say": "reasoning", "text": "thinking..."}, + {"ts": 3, "type": "say", "say": "completion_result", "text": "Done"}, + ] + + +def _backfill_files(task_id, messages): + return { + "file": ("task.json", json.dumps(messages), "application/json"), + }, {"taskId": task_id, "properties": "{}"} + + +# --- Blocker A: org-less settings advertise task sharing with a live version -- + + +async def test_org_less_settings_enable_sharing_with_nonzero_version(db_session): + """Org-less extension settings must advertise task sharing AND carry a + non-zero, content-derived version. The client caches org settings and only + replaces them when `version` changes; a constant 0 leaves an already-cached + (cloudSettings=null) client with the Share button permanently disabled.""" + res = await get_extension_settings(db=db_session, user_id="user_test", org_id=None) + data = res.model_dump(by_alias=True) + org = data["organization"] + assert org["cloudSettings"]["enableTaskSharing"] is True + assert org["cloudSettings"]["allowPublicTaskSharing"] is True + # Must differ from the stale cached default of 0 so the client refreshes. + assert org["version"] != 0 + + +def _find_nulls(obj, path=""): + """Return dotted paths of every JSON `null` found anywhere in the response.""" + out = [] + if isinstance(obj, dict): + for k, v in obj.items(): + out.append(f"{path}.{k}") if v is None else out.extend(_find_nulls(v, f"{path}.{k}")) + elif isinstance(obj, list): + for i, v in enumerate(obj): + out.extend(_find_nulls(v, f"{path}[{i}]")) + return out + + +async def test_extension_settings_http_has_no_null_fields(client, db_session): + """The serialized /api/extension-settings response must contain NO JSON nulls. + + The client parses this with Zod schemas whose optional fields use `.optional()`, + which accepts `undefined` but REJECTS `null`. If Pydantic serializes unset + Optionals as null, the client parse fails, CloudSettingsService never caches the + settings, `canShareTask()` returns false, and the Share button is permanently + disabled. `response_model_exclude_none=True` on the route prevents this. + """ + await _seed_user(db_session) + from src.main import app + + _override_current_user(app) + try: + resp = client.get("/api/extension-settings") + finally: + app.dependency_overrides.pop(get_current_user, None) + + assert resp.status_code == 200 + data = resp.json() + nulls = _find_nulls(data) + assert nulls == [], f"response must not contain null fields, found: {nulls}" + assert data["organization"]["cloudSettings"]["enableTaskSharing"] is True + + +# --- Blocker B: share returns 404 for unknown task ------------------------- + + +async def test_share_unknown_task_returns_404(client, db_session): + await _seed_user(db_session) + from src.main import app + + _override_current_user(app) + try: + resp = client.post( + "/api/extension/share", + json={"taskId": "does-not-exist", "visibility": "organization"}, + ) + finally: + app.dependency_overrides.pop(get_current_user, None) + + assert resp.status_code == 404 + + +async def test_share_existing_task_response_has_no_null_fields( + client, db_session, session_factory +): + """The serialized /api/extension/share success body must contain NO JSON nulls. + + The client parses this with the Zod shareResponseSchema whose optional fields use + `.optional()`, which accepts `undefined` but REJECTS `null`. Without + `response_model_exclude_none=True`, the unset `error` field serializes as null, + the client parse throws, and the extension shows "Failed to share task" even + though the share row was created. `response_model_exclude_none=True` prevents it. + """ + await _seed_user(db_session) + from src.main import app + + _override_current_user(app) + # Backfill first so the parent Task row exists (share 404s otherwise). + files, data = _backfill_files("task-share", _msgs()) + try: + client.post("/api/events/backfill", files=files, data=data) + resp = client.post( + "/api/extension/share", + json={"taskId": "task-share", "visibility": "organization"}, + ) + finally: + app.dependency_overrides.pop(get_current_user, None) + + assert resp.status_code == 200 + body = resp.json() + nulls = _find_nulls(body) + assert nulls == [], f"share response must not contain null fields, found: {nulls}" + assert body["success"] is True + assert "error" not in body # unset Optional must be omitted, never null + assert body["shareUrl"].endswith("/shared/task-share") + + +# --- Blocker C: backfill creates Task + replaces messages ------------------ + + +async def test_backfill_creates_task_and_messages(client, db_session, session_factory): + await _seed_user(db_session) + from src.main import app + + _override_current_user(app) + files, data = _backfill_files("task-1", _msgs()) + try: + resp = client.post("/api/events/backfill", files=files, data=data) + finally: + app.dependency_overrides.pop(get_current_user, None) + + assert resp.status_code == 200 + + async with session_factory() as s: + task = (await s.execute(select(Task).where(Task.id == "task-1"))).scalar_one() + assert task.user_id == "user_test" + n = ( + await s.execute( + select(func.count(TaskMessage.id)).where(TaskMessage.task_id == "task-1") + ) + ).scalar_one() + assert n == 3 + + +async def test_backfill_is_idempotent_on_reshare(client, db_session, session_factory): + await _seed_user(db_session) + from src.main import app + + _override_current_user(app) + try: + files, data = _backfill_files("task-2", _msgs()) + client.post("/api/events/backfill", files=files, data=data) + # Re-share with a different (shorter) message set. + files, data = _backfill_files("task-2", _msgs()[:1]) + client.post("/api/events/backfill", files=files, data=data) + finally: + app.dependency_overrides.pop(get_current_user, None) + + async with session_factory() as s: + n = ( + await s.execute( + select(func.count(TaskMessage.id)).where(TaskMessage.task_id == "task-2") + ) + ).scalar_one() + # Replaced, not appended. + assert n == 1 + tasks = (await s.execute(select(func.count(Task.id)).where(Task.id == "task-2"))).scalar_one() + assert tasks == 1 + + +# --- Web: /app requires a session ------------------------------------------ + + +async def test_app_redirects_to_login_without_session(client): + resp = client.get("/app", follow_redirects=False) + assert resp.status_code == 303 + assert resp.headers["location"] == "/app/login" + + +async def test_app_lists_owned_tasks(client, db_session, session_factory): + await _seed_user(db_session) + async with session_factory() as s: + s.add(Task(id="task-9", user_id="user_test")) + s.add(TaskMessage(task_id="task-9", message_data=json.dumps(_msgs()[0]))) + await s.commit() + + from src.main import app + + _override_web_user(app) + try: + resp = client.get("/app") + finally: + app.dependency_overrides.pop(get_web_user_optional, None) + + assert resp.status_code == 200 + assert "Build me a feature" in resp.text + + +# --- Web: task detail enforces ownership ----------------------------------- + + +async def test_task_detail_not_found_for_non_owner(client, db_session, session_factory): + await _seed_user(db_session, user_id="owner", email="owner@example.com") + async with session_factory() as s: + s.add(Task(id="task-owned", user_id="owner")) + await s.commit() + + from src.main import app + + _override_web_user(app, user_id="intruder") + try: + resp = client.get("/app/tasks/task-owned") + finally: + app.dependency_overrides.pop(get_web_user_optional, None) + + assert resp.status_code == 404 + + +# --- Web: /shared honours visibility --------------------------------------- + + +async def test_shared_public_allows_anonymous(client, db_session, session_factory): + await _seed_user(db_session) + async with session_factory() as s: + s.add(Task(id="task-pub", user_id="user_test")) + s.add(TaskMessage(task_id="task-pub", message_data=json.dumps(_msgs()[0]))) + s.add( + TaskShare( + task_id="task-pub", + visibility="public", + share_url="http://testserver/shared/task-pub", + ) + ) + await s.commit() + + resp = client.get("/shared/task-pub") + assert resp.status_code == 200 + assert "Build me a feature" in resp.text + + +async def test_shared_private_requires_login(client, db_session, session_factory): + await _seed_user(db_session) + async with session_factory() as s: + s.add(Task(id="task-priv", user_id="user_test")) + s.add(TaskShare(task_id="task-priv", visibility="organization")) + await s.commit() + + resp = client.get("/shared/task-priv", follow_redirects=False) + assert resp.status_code == 303 + assert resp.headers["location"] == "/app/login" + + +async def test_shared_unknown_returns_404(client): + resp = client.get("/shared/nope") + assert resp.status_code == 404 + + +# --- Web: live remote-control surface only on the owner page ---------------- + + +async def test_owner_task_detail_renders_live_controls( + client, db_session, session_factory, monkeypatch +): + """The owner's task page must expose the interactive bridge surface: the + live header, the chat/auto-approve controls, and the live.js loader — fed by + the embedded live-config. This is what makes the page drive the task. The + page reads `settings.bridge_enabled` per request, so enable it here.""" + from config.settings import settings as app_settings + + monkeypatch.setattr(app_settings, "bridge_enabled", True) + + await _seed_user(db_session) + async with session_factory() as s: + s.add(Task(id="task-live", user_id="user_test")) + s.add(TaskMessage(task_id="task-live", message_data=json.dumps(_msgs()[0]))) + await s.commit() + + from src.main import app + + _override_web_user(app) + try: + resp = client.get("/app/tasks/task-live") + finally: + app.dependency_overrides.pop(get_web_user_optional, None) + + assert resp.status_code == 200 + body = resp.text + assert 'id="live-controls"' in body + assert 'id="chat-input"' in body + assert 'id="live-config"' in body + assert "/static/live.js" in body + # The config must carry the task id and the bridge path for the client. + assert '"taskId": "task-live"' in body + + +async def test_shared_page_anonymous_never_renders_live_controls( + client, db_session, session_factory, monkeypatch +): + """A public share link viewed anonymously is strictly read-only — it must NOT + ship the live controls or the socket.io/live.js bundle, even when the bridge is + enabled. Control is owner-only.""" + from config.settings import settings as app_settings + + monkeypatch.setattr(app_settings, "bridge_enabled", True) + + await _seed_user(db_session) + async with session_factory() as s: + s.add(Task(id="task-pub2", user_id="user_test")) + s.add(TaskMessage(task_id="task-pub2", message_data=json.dumps(_msgs()[0]))) + s.add( + TaskShare( + task_id="task-pub2", + visibility="public", + share_url="http://testserver/shared/task-pub2", + ) + ) + await s.commit() + + resp = client.get("/shared/task-pub2") + assert resp.status_code == 200 + body = resp.text + assert 'id="live-controls"' not in body + assert "/static/live.js" not in body + + +async def test_shared_owner_gets_live_controls( + client, db_session, session_factory, monkeypatch +): + """The owner opening their own share URL gets the live, drivable surface — so a + freshly-shared task is remote-controllable straight from its share link.""" + from config.settings import settings as app_settings + + monkeypatch.setattr(app_settings, "bridge_enabled", True) + + await _seed_user(db_session) + async with session_factory() as s: + s.add(Task(id="task-own-live", user_id="user_test")) + s.add(TaskMessage(task_id="task-own-live", message_data=json.dumps(_msgs()[0]))) + s.add( + TaskShare( + task_id="task-own-live", + visibility="public", + share_url="http://testserver/shared/task-own-live", + ) + ) + await s.commit() + + from src.main import app + + _override_web_user(app) # logged in as "user_test" (the owner) + try: + resp = client.get("/shared/task-own-live") + finally: + app.dependency_overrides.pop(get_web_user_optional, None) + + assert resp.status_code == 200 + body = resp.text + assert 'id="live-controls"' in body + assert "/static/live.js" in body + assert '"taskId": "task-own-live"' in body + # The owner driving their own task is not "read-only". + assert "read-only" not in body + + +async def test_delete_task_removes_task_messages_and_share( + client, db_session, session_factory +): + """Owner deleting a task wipes the Task row and everything hanging off it — + messages and share rows — from the DB, and redirects back to the list.""" + await _seed_user(db_session) + async with session_factory() as s: + s.add(Task(id="task-del", user_id="user_test")) + s.add(TaskMessage(task_id="task-del", message_data=json.dumps(_msgs()[0]))) + s.add( + TaskShare( + task_id="task-del", + visibility="public", + share_url="http://testserver/shared/task-del", + ) + ) + await s.commit() + + from src.main import app + + _override_web_user(app) + try: + resp = client.post("/app/tasks/task-del/delete", follow_redirects=False) + finally: + app.dependency_overrides.pop(get_web_user_optional, None) + + assert resp.status_code == 303 + assert resp.headers["location"] == "/app" + + async with session_factory() as s: + tasks = ( + await s.execute(select(func.count(Task.id)).where(Task.id == "task-del")) + ).scalar_one() + msgs = ( + await s.execute( + select(func.count(TaskMessage.id)).where(TaskMessage.task_id == "task-del") + ) + ).scalar_one() + shares = ( + await s.execute( + select(func.count(TaskShare.id)).where(TaskShare.task_id == "task-del") + ) + ).scalar_one() + assert tasks == 0 + assert msgs == 0 + assert shares == 0 + + +async def test_delete_task_non_owner_is_noop(client, db_session, session_factory): + """A non-owner POSTing the delete route never touches another user's data: + the task and its messages survive (silent no-op, still a 303 to the list).""" + await _seed_user(db_session, user_id="owner", email="owner@example.com") + async with session_factory() as s: + s.add(Task(id="task-keep", user_id="owner")) + s.add(TaskMessage(task_id="task-keep", message_data=json.dumps(_msgs()[0]))) + await s.commit() + + from src.main import app + + _override_web_user(app, user_id="intruder", email="intruder@example.com") + try: + resp = client.post("/app/tasks/task-keep/delete", follow_redirects=False) + finally: + app.dependency_overrides.pop(get_web_user_optional, None) + + assert resp.status_code == 303 + async with session_factory() as s: + tasks = ( + await s.execute(select(func.count(Task.id)).where(Task.id == "task-keep")) + ).scalar_one() + assert tasks == 1 + + +async def test_delete_task_requires_session(client): + """An unauthenticated delete POST redirects to login and deletes nothing.""" + resp = client.post("/app/tasks/whatever/delete", follow_redirects=False) + assert resp.status_code == 303 + assert resp.headers["location"] == "/app/login" + + +async def test_shared_link_404s_after_owner_deletes( + client, db_session, session_factory +): + """Once the owner deletes the task, its public /shared link 404s.""" + await _seed_user(db_session) + async with session_factory() as s: + s.add(Task(id="task-gone", user_id="user_test")) + s.add(TaskMessage(task_id="task-gone", message_data=json.dumps(_msgs()[0]))) + s.add( + TaskShare( + task_id="task-gone", + visibility="public", + share_url="http://testserver/shared/task-gone", + ) + ) + await s.commit() + + # Visible before delete. + assert client.get("/shared/task-gone").status_code == 200 + + from src.main import app + + _override_web_user(app) + try: + client.post("/app/tasks/task-gone/delete") + finally: + app.dependency_overrides.pop(get_web_user_optional, None) + + assert client.get("/shared/task-gone").status_code == 404 + + +async def test_shared_nonowner_stays_readonly( + client, db_session, session_factory, monkeypatch +): + """A logged-in viewer who does NOT own the task gets the read-only share view — + control never leaks to non-owners.""" + from config.settings import settings as app_settings + + monkeypatch.setattr(app_settings, "bridge_enabled", True) + + await _seed_user(db_session, user_id="owner", email="owner@example.com") + async with session_factory() as s: + s.add(Task(id="task-other", user_id="owner")) + s.add(TaskMessage(task_id="task-other", message_data=json.dumps(_msgs()[0]))) + s.add( + TaskShare( + task_id="task-other", + visibility="public", + share_url="http://testserver/shared/task-other", + ) + ) + await s.commit() + + from src.main import app + + _override_web_user(app, user_id="intruder", email="intruder@example.com") + try: + resp = client.get("/shared/task-other") + finally: + app.dependency_overrides.pop(get_web_user_optional, None) + + assert resp.status_code == 200 + body = resp.text + assert 'id="live-controls"' not in body + assert "/static/live.js" not in body diff --git a/self-hosted-cloudapi/uv.lock b/self-hosted-cloudapi/uv.lock index 49e9bb97d2..625edca58f 100644 --- a/self-hosted-cloudapi/uv.lock +++ b/self-hosted-cloudapi/uv.lock @@ -96,6 +96,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/3c/d7/8fb3044eaef08a310acfe23dae9a8e2e07d305edc29a53497e52bc76eca7/asyncpg-0.31.0-cp314-cp314t-win_amd64.whl", hash = "sha256:bd4107bb7cdd0e9e65fae66a62afd3a249663b844fa34d479f6d5b3bef9c04c3", size = 706062, upload-time = "2025-11-24T23:26:44.086Z" }, ] +[[package]] +name = "bidict" +version = "0.23.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/9a/6e/026678aa5a830e07cd9498a05d3e7e650a4f56a42f267a53d22bcda1bdc9/bidict-0.23.1.tar.gz", hash = "sha256:03069d763bc387bbd20e7d49914e75fc4132a41937fa3405417e1a5a2d006d71", size = 29093, upload-time = "2024-02-18T19:09:05.748Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/99/37/e8730c3587a65eb5645d4aba2d27aae48e8003614d6aaf15dda67f702f1f/bidict-0.23.1-py3-none-any.whl", hash = "sha256:5dae8d4d79b552a71cbabc7deb25dfe8ce710b17ff41711e13010ead2abfc3e5", size = 32764, upload-time = "2024-02-18T19:09:04.156Z" }, +] + [[package]] name = "certifi" version = "2026.4.22" @@ -492,6 +501,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/04/96/92447566d16df59b2a776c0fb82dbc4d9e07cd95062562af01e408583fc4/itsdangerous-2.2.0-py3-none-any.whl", hash = "sha256:c6242fc49e35958c8b15141343aa660db5fc54d4f13a1db01a3f5891b98700ef", size = 16234, upload-time = "2024-04-16T21:28:14.499Z" }, ] +[[package]] +name = "jinja2" +version = "3.1.6" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markupsafe" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/df/bf/f7da0350254c0ed7c72f3e33cef02e048281fec7ecec5f032d4aac52226b/jinja2-3.1.6.tar.gz", hash = "sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d", size = 245115, upload-time = "2025-03-05T20:05:02.478Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899, upload-time = "2025-03-05T20:05:00.369Z" }, +] + [[package]] name = "limits" version = "5.8.0" @@ -782,6 +803,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/0b/d7/1959b9648791274998a9c3526f6d0ec8fd2233e4d4acce81bbae76b44b2a/python_dotenv-1.2.2-py3-none-any.whl", hash = "sha256:1d8214789a24de455a8b8bd8ae6fe3c6b69a5e3d64aa8a8e5d68e694bbcb285a", size = 22101, upload-time = "2026-03-01T16:00:25.09Z" }, ] +[[package]] +name = "python-engineio" +version = "4.13.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "simple-websocket" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/fa/6d/4384c2723adad93a3d6de4297e6d9c8b93be7f778a407f34f6ee0b2bea3e/python_engineio-4.13.2.tar.gz", hash = "sha256:a7732e99cfb7db6ed1aee31f18d7f73bbae086a92f31dee019bc646155d9684e", size = 79639, upload-time = "2026-05-21T21:45:07.578Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b7/28/180bfc5c95e83d40cb2abce512684ccad44e4819ec899fc36cb404a19061/python_engineio-4.13.2-py3-none-any.whl", hash = "sha256:8c101cd170e400dc4e970cd523325cde22df8fc25140953f379327055d701a6b", size = 59993, upload-time = "2026-05-21T21:45:06.162Z" }, +] + [[package]] name = "python-jose" version = "3.5.0" @@ -810,6 +843,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/99/78/4126abcbdbd3c559d43e0db7f7b9173fc6befe45d39a2856cc0b8ec2a5a6/python_multipart-0.0.27-py3-none-any.whl", hash = "sha256:6fccfad17a27334bd0193681b369f476eda3409f17381a2d65aa7df3f7275645", size = 29254, upload-time = "2026-04-27T10:51:24.997Z" }, ] +[[package]] +name = "python-socketio" +version = "5.16.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "bidict" }, + { name = "python-engineio" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/32/2d/ffce71017c106b75099fea569df6518c63fee5d6202ce0cfe7b01e6f22c3/python_socketio-5.16.3.tar.gz", hash = "sha256:89b136f677ae65607a84cecda9b4d6c5377b40a97582c504c25df89af16d520e", size = 128095, upload-time = "2026-06-15T22:07:04.003Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0a/38/8c5e72d53ff8eb27497c4f268a7f6d9121e727a50b65248288ad79a93053/python_socketio-5.16.3-py3-none-any.whl", hash = "sha256:e7ad14202a5e6448824c7c2f86161d04e13dec05992257df5c709e6a2798c041", size = 82087, upload-time = "2026-06-15T22:07:02.498Z" }, +] + [[package]] name = "pyyaml" version = "6.0.3" @@ -866,10 +912,12 @@ dependencies = [ { name = "fastapi" }, { name = "httpx" }, { name = "itsdangerous" }, + { name = "jinja2" }, { name = "pydantic" }, { name = "pydantic-settings" }, { name = "python-jose", extra = ["cryptography"] }, { name = "python-multipart" }, + { name = "python-socketio" }, { name = "pyyaml" }, { name = "slowapi" }, { name = "sqlalchemy" }, @@ -895,6 +943,7 @@ requires-dist = [ { name = "httpx", specifier = ">=0.28.0" }, { name = "httpx", marker = "extra == 'dev'", specifier = ">=0.28.0" }, { name = "itsdangerous", specifier = ">=2.2.0" }, + { name = "jinja2", specifier = ">=3.1.4" }, { name = "pydantic", specifier = ">=2.10.0" }, { name = "pydantic-settings", specifier = ">=2.7.0" }, { name = "pytest", marker = "extra == 'dev'", specifier = ">=8.3.0" }, @@ -902,6 +951,7 @@ requires-dist = [ { name = "pytest-cov", marker = "extra == 'dev'", specifier = ">=6.0.0" }, { name = "python-jose", extras = ["cryptography"], specifier = ">=3.3.0" }, { name = "python-multipart", specifier = ">=0.0.20" }, + { name = "python-socketio", specifier = ">=5.11.0" }, { name = "pyyaml", specifier = ">=6.0.2" }, { name = "slowapi", specifier = ">=0.1.9" }, { name = "sqlalchemy", specifier = ">=2.0.36" }, @@ -922,6 +972,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/64/8d/0133e4eb4beed9e425d9a98ed6e081a55d195481b7632472be1af08d2f6b/rsa-4.9.1-py3-none-any.whl", hash = "sha256:68635866661c6836b8d39430f97a996acbd61bfa49406748ea243539fe239762", size = 34696, upload-time = "2025-04-16T09:51:17.142Z" }, ] +[[package]] +name = "simple-websocket" +version = "1.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "wsproto" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b0/d4/bfa032f961103eba93de583b161f0e6a5b63cebb8f2c7d0c6e6efe1e3d2e/simple_websocket-1.1.0.tar.gz", hash = "sha256:7939234e7aa067c534abdab3a9ed933ec9ce4691b0713c78acb195560aa52ae4", size = 17300, upload-time = "2024-10-10T22:39:31.412Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/52/59/0782e51887ac6b07ffd1570e0364cf901ebc36345fea669969d2084baebb/simple_websocket-1.1.0-py3-none-any.whl", hash = "sha256:4af6069630a38ed6c561010f0e11a5bc0d4ca569b36306eb257cd9a192497c8c", size = 13842, upload-time = "2024-10-10T22:39:29.645Z" }, +] + [[package]] name = "six" version = "1.17.0" @@ -1257,3 +1319,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/5c/99/79f17046cf67e4a95b9987ea129632ba8bcec0bc81f3fb3d19bdb0bd60cd/wrapt-2.1.2-cp314-cp314t-win_arm64.whl", hash = "sha256:72aaa9d0d8e4ed0e2e98019cea47a21f823c9dd4b43c7b77bba6679ffcca6a00", size = 60554, upload-time = "2026-03-06T02:53:14.132Z" }, { url = "https://files.pythonhosted.org/packages/1a/c7/8528ac2dfa2c1e6708f647df7ae144ead13f0a31146f43c7264b4942bf12/wrapt-2.1.2-py3-none-any.whl", hash = "sha256:b8fd6fa2b2c4e7621808f8c62e8317f4aae56e59721ad933bac5239d913cf0e8", size = 43993, upload-time = "2026-03-06T02:53:12.905Z" }, ] + +[[package]] +name = "wsproto" +version = "1.3.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c7/79/12135bdf8b9c9367b8701c2c19a14c913c120b882d50b014ca0d38083c2c/wsproto-1.3.2.tar.gz", hash = "sha256:b86885dcf294e15204919950f666e06ffc6c7c114ca900b060d6e16293528294", size = 50116, upload-time = "2025-11-20T18:18:01.871Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a4/f5/10b68b7b1544245097b2a1b8238f66f2fc6dcaeb24ba5d917f52bd2eed4f/wsproto-1.3.2-py3-none-any.whl", hash = "sha256:61eea322cdf56e8cc904bd3ad7573359a242ba65688716b0710a5eb12beab584", size = 24405, upload-time = "2025-11-20T18:18:00.454Z" }, +] diff --git a/src/extension.ts b/src/extension.ts index 541c636e4e..be46fed7b6 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -42,6 +42,7 @@ import { migrateSettings } from "./utils/migrateSettings" import { migrateFromRooCode } from "./utils/migrateFromRooCode" import { autoImportSettings } from "./utils/autoImportSettings" import { API } from "./extension/api" +import { setupRemoteControlBridge } from "./extension/bridge" import { handleUri, @@ -385,7 +386,23 @@ export async function activate(context: vscode.ExtensionContext) { // Initialize background model cache refresh initializeModelCacheRefresh() - return new API(outputChannel, provider, socketPath, enableLogging) + const api = new API(outputChannel, provider, socketPath, enableLogging) + + // Wire the opt-in live remote-control bridge (extension ↔ backend ↔ browser). + try { + setupRemoteControlBridge({ + context, + api, + provider, + log: (message: string) => outputChannel.appendLine(message), + }) + } catch (error) { + outputChannel.appendLine( + `[bridge] Failed to set up remote control bridge: ${error instanceof Error ? error.message : String(error)}`, + ) + } + + return api } // This method is called when your extension is deactivated. diff --git a/src/extension/bridge.ts b/src/extension/bridge.ts new file mode 100644 index 0000000000..f078a6cd53 --- /dev/null +++ b/src/extension/bridge.ts @@ -0,0 +1,142 @@ +import * as vscode from "vscode" + +import { + BridgeOrchestrator, + CloudService, + type BridgeEventSource, + type BridgeProvider, + type InstanceStatePayload, +} from "@roo-code/cloud" + +import { TaskStatus } from "@roo-code/types" + +import type { ClineProvider } from "../core/webview/ClineProvider" +import type { API } from "./api" + +/** + * Wire the live remote-control bridge to the extension. There is no opt-in + * setting: the bridge is bound to the cloud session. A cloud session is what + * supplies the bridge token + user identity, so the orchestrator starts on + * sign-in and stops on sign-out — once you are logged into the cloud, a task is + * remote-controllable the moment it is shared. + * + * Hard constraint: all traffic is extension ↔ backend ↔ browser. This connects + * the extension to the backend socket.io relay; there is never a direct + * VS Code ↔ browser link. + */ +export function setupRemoteControlBridge(opts: { + context: vscode.ExtensionContext + api: API + provider: ClineProvider + log: (message: string) => void +}): void { + const { context, api, provider, log } = opts + + let orchestrator: BridgeOrchestrator | null = null + + const isAuthenticated = () => CloudService.hasInstance() && CloudService.instance.isAuthenticated() + + const bridgeProvider: BridgeProvider = { + getCurrentTask: () => provider.getCurrentTask() as unknown as ReturnType, + cancelTask: () => provider.cancelTask(), + showTaskWithId: (id: string) => provider.showTaskWithId(id), + postStateToWebview: () => provider.postStateToWebview(), + contextProxy: { + setValue: (key: string, value: unknown) => provider.contextProxy.setValue(key as any, value as any), + }, + } + + const snapshot = async (taskId: string): Promise => { + const task = provider.getCurrentTask() + const state = await provider.getState() + const tokenUsage = task?.getTokenUsage?.() + let contextWindow: number | undefined + try { + contextWindow = task?.api?.getModel().info.contextWindow + } catch { + contextWindow = undefined + } + // `task.abort` only flips after an explicit abort, so an idle task (turn + // finished, awaiting input) would falsely report running and keep the web + // cockpit's Stop button live. `taskStatus` is the authoritative signal: + // running while streaming or blocked on an interactive approval, idle/ + // resumable once the turn is done. + const status = task?.taskStatus + const isRunning = status === TaskStatus.Running || status === TaskStatus.Interactive + return { + mode: state.mode, + isRunning, + autoApproval: { + autoApprovalEnabled: state.autoApprovalEnabled, + autoApprovalMode: state.autoApprovalMode, + alwaysAllowReadOnly: state.alwaysAllowReadOnly, + alwaysAllowWrite: state.alwaysAllowWrite, + alwaysAllowExecute: state.alwaysAllowExecute, + alwaysAllowMcp: state.alwaysAllowMcp, + alwaysAllowModeSwitch: state.alwaysAllowModeSwitch, + alwaysAllowSubtasks: state.alwaysAllowSubtasks, + }, + tokenUsage, + contextTokens: tokenUsage?.contextTokens, + contextWindow, + currentAsk: task?.taskAsk, + } + } + + const start = async () => { + if (orchestrator) return + const cloudAPI = CloudService.hasInstance() ? CloudService.instance.cloudAPI : null + if (!cloudAPI || !CloudService.instance.isAuthenticated()) { + log("[bridge] no active cloud session; will connect after sign-in") + return + } + const workspacePath = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath ?? "" + orchestrator = new BridgeOrchestrator({ + getBridgeConfig: () => cloudAPI.bridgeConfig(), + provider: bridgeProvider, + events: api as unknown as BridgeEventSource, + workspacePath, + snapshot, + log: (...args: unknown[]) => log(`[bridge] ${args.map(String).join(" ")}`), + }) + try { + await orchestrator.start() + log("[bridge] remote control bridge connected") + } catch (error) { + log(`[bridge] failed to start: ${error instanceof Error ? error.message : String(error)}`) + orchestrator = null + } + } + + const stop = async () => { + if (!orchestrator) return + await orchestrator.stop() + orchestrator = null + log("[bridge] remote control bridge disconnected") + } + + // Registered as the synchronous `auth-state-changed` listener below. The cloud + // AuthService emits that event from inside changeState()/refreshSession(), so any + // exception thrown here would propagate up and corrupt the auth state machine + // (it once logged the user out on every backend restart). Never let it throw. + const reconcile = () => { + try { + if (isAuthenticated()) { + void start() + } else { + void stop() + } + } catch (error) { + log(`[bridge] reconcile error: ${error instanceof Error ? error.message : String(error)}`) + } + } + + // Follow the cloud session: connect on sign-in, disconnect on sign-out. + if (CloudService.hasInstance()) { + CloudService.instance.on("auth-state-changed", reconcile) + context.subscriptions.push({ dispose: () => CloudService.instance.off("auth-state-changed", reconcile) }) + } + context.subscriptions.push({ dispose: () => void stop() }) + + reconcile() +}