mirror of
https://github.com/lobehub/lobe-chat.git
synced 2026-06-20 14:20:27 +00:00
Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 01ac0c438a |
@@ -19,23 +19,9 @@ also run as full cloud automation. Every test session follows the same
|
||||
four-step contract:
|
||||
|
||||
```
|
||||
Step -1: Plan approval → Step 0: Env + Auth → Step 1: Pick surface → Step 2: Run → Step 3: Structured report
|
||||
Step 0: Env + Auth → Step 1: Pick surface → Step 2: Run → Step 3: Structured report
|
||||
```
|
||||
|
||||
## Step -1 — Plan approval for non-trivial tests
|
||||
|
||||
Skip directly to Step 0 if: the test is a single re-run after a fix, the plan
|
||||
was already agreed on, or the user gave exact commands.
|
||||
|
||||
Otherwise, propose a test plan (surface, cases, expected evidence, assumptions)
|
||||
and use the runtime structured question tool (`request_user_input` /
|
||||
ask-user-question equivalent) with two fixed choices:
|
||||
|
||||
1. `开始执行 (Recommended)` — 测试方案没问题,开始执行
|
||||
2. `先讨论下` — 方案有问题,先讨论下
|
||||
|
||||
Wait for the user's choice before proceeding.
|
||||
|
||||
## Step 0 — Environment setup + auth check (mandatory)
|
||||
|
||||
Step 0 is about getting the environment ready: **dependencies are healthy**
|
||||
@@ -43,36 +29,6 @@ and **auth is green**. A test run that dies halfway on a missing dependency or
|
||||
a login wall wastes the whole session — clear both gates BEFORE writing a
|
||||
single test step.
|
||||
|
||||
### 0.0 Resolve the current test environment
|
||||
|
||||
Before starting a dev server, checking auth, opening agent-browser, or writing
|
||||
test steps, print and confirm the current local test environment:
|
||||
|
||||
```bash
|
||||
./.agents/skills/agent-testing/scripts/test-env.sh
|
||||
```
|
||||
|
||||
This command is the source of truth for local test ports. It reads the current
|
||||
shell plus `.env` files using the same precedence as `scripts/runWithEnv.mts`,
|
||||
then prints:
|
||||
|
||||
- `APP_URL`
|
||||
- `PORT`
|
||||
- `SERVER_URL`
|
||||
- `AUTH_TRUSTED_ORIGINS`
|
||||
- `SPA_PORT`
|
||||
- `MOBILE_SPA_PORT`
|
||||
- `DESKTOP_PORT`
|
||||
|
||||
For commands that need these values, export them from the same resolver:
|
||||
|
||||
```bash
|
||||
eval "$(./.agents/skills/agent-testing/scripts/test-env.sh --exports)"
|
||||
```
|
||||
|
||||
Do not rely on hard-coded port tables. If the printed values do not match the
|
||||
running dev server, fix/export the env first, then continue.
|
||||
|
||||
### 0.1 Dependencies are installed — root AND standalone apps
|
||||
|
||||
The root pnpm workspace does **NOT** cover every app: `pnpm-workspace.yaml`
|
||||
@@ -157,12 +113,10 @@ full-stack `dev` command so Next can proxy the SPA HTML from Vite:
|
||||
Useful subcommands:
|
||||
|
||||
```bash
|
||||
./.agents/skills/agent-testing/scripts/init-dev-env.sh env # print exports
|
||||
./.agents/skills/agent-testing/scripts/init-dev-env.sh write # write .records/env/agent-testing-dev.env
|
||||
./.agents/skills/agent-testing/scripts/init-dev-env.sh migrate # migrations only
|
||||
./.agents/skills/agent-testing/scripts/init-dev-env.sh seed-user # seed user + CLI API key
|
||||
./.agents/skills/agent-testing/scripts/init-dev-env.sh qstash # local QStash for workflow paths
|
||||
./.agents/skills/agent-testing/scripts/init-dev-env.sh clean-db # remove managed DB container
|
||||
./.agents/skills/agent-testing/scripts/init-dev-env.sh env # print exports
|
||||
./.agents/skills/agent-testing/scripts/init-dev-env.sh write # write .records/env/agent-testing-dev.env
|
||||
./.agents/skills/agent-testing/scripts/init-dev-env.sh migrate # migrations only
|
||||
./.agents/skills/agent-testing/scripts/init-dev-env.sh clean-db # remove managed DB container
|
||||
```
|
||||
|
||||
Default script env:
|
||||
@@ -171,18 +125,14 @@ Default script env:
|
||||
- `DATABASE_URL=postgresql://postgres:postgres@localhost:5433/postgres`
|
||||
- `DATABASE_DRIVER=node`
|
||||
- `FEATURE_FLAGS=-agent_self_iteration` so local smoke does not require QStash
|
||||
- Local QStash defaults (`QSTASH_URL`, `QSTASH_TOKEN`, signing keys) are exported;
|
||||
run `init-dev-env.sh qstash` in a separate terminal when the path under test
|
||||
triggers QStash/Workflow.
|
||||
- `KEY_VAULTS_SECRET`, `AUTH_SECRET`, auth verification off
|
||||
- S3 mock vars
|
||||
- Managed DB container: `lobehub-agent-testing-postgres`
|
||||
|
||||
`seed-user` creates `agent-testing@lobehub.com` / `TestPassword123!` with
|
||||
onboarding already completed, plus a local API key in
|
||||
`.records/env/agent-testing-cli.env` for CLI automation. When running Cucumber
|
||||
against this dev server, pass the same script env into the test process too;
|
||||
Cucumber has its own `BeforeAll` seed path and it must see `DATABASE_URL`
|
||||
onboarding already completed for manual or agent-browser checks. When running
|
||||
Cucumber against this dev server, pass the same script env into the test process
|
||||
too; Cucumber has its own `BeforeAll` seed path and it must see `DATABASE_URL`
|
||||
instead of silently skipping setup:
|
||||
|
||||
```bash
|
||||
@@ -192,36 +142,27 @@ eval "$(../.agents/skills/agent-testing/scripts/init-dev-env.sh env)"
|
||||
BASE_URL=http://localhost:3010 HEADLESS=true bun run test:smoke
|
||||
```
|
||||
|
||||
### 0.4 Auth is green for the selected surface
|
||||
### 0.4 Auth is green
|
||||
|
||||
**Auth is the gate for automated testing, but the gate is surface-scoped.**
|
||||
Pick the intended surface first when it is already clear from the task, then
|
||||
check only that surface. Do not block a Web test on CLI device-code auth or an
|
||||
Electron login state unless the test spans those surfaces.
|
||||
**Auth is the gate for all automated testing.**
|
||||
|
||||
```bash
|
||||
./.agents/skills/agent-testing/scripts/setup-auth.sh status --surface web
|
||||
./.agents/skills/agent-testing/scripts/setup-auth.sh status
|
||||
```
|
||||
|
||||
Use `status` with no `--surface` only for cross-surface test plans.
|
||||
|
||||
| Surface | Mechanism | One-key path | Standard check |
|
||||
| -------- | --------------------------------------------- | ------------------------ | ----------------------------------------- |
|
||||
| CLI | Seeded API key, device-code fallback | `setup-auth.sh cli-seed` | `setup-auth.sh status --surface cli` |
|
||||
| Web | Seeded better-auth login into `agent-browser` | `setup-auth.sh web-seed` | `setup-auth.sh status --surface web` |
|
||||
| Electron | App's own persistent login state | Log in once in the app | `setup-auth.sh status --surface electron` |
|
||||
| Bot | Native apps already logged in | — | per-platform screenshot |
|
||||
| Surface | Mechanism | One-key path | Standard check |
|
||||
| -------- | ------------------------------------------------- | ------------------------------ | -------------------------- |
|
||||
| CLI | OIDC Device Code Flow (`apps/cli/.lobehub-dev`) | `setup-auth.sh cli` | `setup-auth.sh status` |
|
||||
| Web | better-auth cookie injection into `agent-browser` | `pbpaste \| setup-auth.sh web` | `setup-auth.sh web-verify` |
|
||||
| Electron | App's own persistent login state | Log in once in the app | `app-probe.sh auth` |
|
||||
| Bot | Native apps already logged in | — | per-platform screenshot |
|
||||
|
||||
Login-state checks are standardized — do NOT hand-roll `window.__LOBE_STORES`
|
||||
eval snippets; use `scripts/app-probe.sh auth` (returns `{ isSignedIn, userId }`,
|
||||
works for Electron CDP and web sessions via `AB_TARGET`).
|
||||
|
||||
For Web tests, the test surface is always `agent-browser --session lobehub-dev`.
|
||||
Use `setup-auth.sh web-seed` first in the seeded local env. The user's normal
|
||||
Chrome is only a source for copying the Cookie header when seed auth is not
|
||||
available or `status --surface web` still fails. If Chrome is already logged in,
|
||||
do not open a login page; verify agent-browser first, then request the Network
|
||||
`Cookie:` header only if that verification fails. Full background and failure modes:
|
||||
If `status` is not all green, fix auth first (the steps that need a human must be
|
||||
requested from the user explicitly). Full background and failure modes:
|
||||
[references/auth.md](./references/auth.md).
|
||||
|
||||
## Step 1 — Pick the surface by change scope
|
||||
@@ -296,7 +237,6 @@ All under `.agents/skills/agent-testing/scripts/`:
|
||||
|
||||
| Script | Usage |
|
||||
| ------------------------- | ---------------------------------------------------------------------------- |
|
||||
| `test-env.sh` | Print/export the resolved local test env and ports |
|
||||
| `setup-auth.sh` | One-stop auth setup & status check (`status` / `cli` / `web`) |
|
||||
| `init-dev-env.sh` | Self-contained local dev env (`setup-db` / `seed-user` / `dev-next` / `dev`) |
|
||||
| `app-probe.sh` | LobeHub app probes: `auth` / `route` / `ops` / `goto <path>` / `errors` |
|
||||
@@ -344,13 +284,6 @@ Two hard rules worth front-loading:
|
||||
must use Markdown image syntax like ``. Do not
|
||||
use bare file paths, Markdown links, or local file links as the primary
|
||||
visual evidence; those make the report unreadable without opening each asset.
|
||||
- **Final replies must include visual evidence links.** When a run includes UI
|
||||
screenshots or GIFs, include the report directory and the most important
|
||||
visual artifacts in the final chat response. Each item must include a stable
|
||||
label, an evidence caption describing the observed UI outcome, and a
|
||||
repo-relative path, for example:
|
||||
`[Image #1 - error toast shows provider auth failure](<report-dir>/assets/foo.png)`.
|
||||
Use repo-relative paths, not absolute paths.
|
||||
- **Time-based behavior needs a GIF, not a screenshot.** If a case asserts
|
||||
change over time (streaming output, a ticking timer, loading states,
|
||||
animations), record it with `scripts/record-gif.sh` and embed the GIF —
|
||||
|
||||
@@ -13,18 +13,17 @@ flakiness.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
| Requirement | Details |
|
||||
| ------------ | ---------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| Dev server | `localhost:3010` — see [../references/dev-server.md](../references/dev-server.md) |
|
||||
| Requirement | Details |
|
||||
| ------------ | --------------------------------------------------------------------------------- |
|
||||
| Dev server | `localhost:3010` — see [../references/dev-server.md](../references/dev-server.md) |
|
||||
| CLI source | `apps/cli/` — runs from source, no rebuild; standalone `node_modules` — run `pnpm install` inside `apps/cli/` (root install does not cover it) |
|
||||
| CLI dev mode | `LOBEHUB_CLI_HOME=.lobehub-dev` for isolated settings |
|
||||
| Auth | Seeded API key first; Device Code Flow only as fallback — see [../references/auth.md](../references/auth.md) |
|
||||
| CLI dev mode | `LOBEHUB_CLI_HOME=.lobehub-dev` for isolated credentials |
|
||||
| Auth | Device Code Flow login — see [../references/auth.md](../references/auth.md) |
|
||||
|
||||
All CLI dev commands run from `apps/cli/`. Subsequent examples use `$CLI`:
|
||||
|
||||
```bash
|
||||
source ../../.records/env/agent-testing-cli.env
|
||||
CLI="bun src/index.ts"
|
||||
CLI="LOBEHUB_CLI_HOME=.lobehub-dev bun src/index.ts"
|
||||
```
|
||||
|
||||
## Workflow
|
||||
@@ -40,23 +39,14 @@ check, start, and restart commands. Server-side code changes require a restart.
|
||||
./.agents/skills/agent-testing/scripts/setup-auth.sh status
|
||||
```
|
||||
|
||||
If the CLI is not ready in the seeded local environment:
|
||||
|
||||
```bash
|
||||
./.agents/skills/agent-testing/scripts/init-dev-env.sh seed-user
|
||||
source .records/env/agent-testing-cli.env
|
||||
./.agents/skills/agent-testing/scripts/setup-auth.sh cli-seed
|
||||
```
|
||||
|
||||
If the target environment is not seeded, use the interactive fallback:
|
||||
If the CLI is not logged in, **the user must run the login themselves**
|
||||
(interactive browser authorization):
|
||||
|
||||
```bash
|
||||
cd apps/cli && LOBEHUB_CLI_HOME=.lobehub-dev bun src/index.ts login --server http://localhost:3010
|
||||
```
|
||||
|
||||
Seeded API-key auth does not store credentials. It writes local settings under
|
||||
`$HOME/.lobehub-dev` and requires the generated env file to be sourced before
|
||||
CLI commands. Details:
|
||||
Credentials persist in `apps/cli/.lobehub-dev/`. Details:
|
||||
[../references/auth.md](../references/auth.md).
|
||||
|
||||
### Step 3 — Test with CLI commands
|
||||
@@ -143,10 +133,10 @@ $CLI provider test <provider-id>
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
| Issue | Solution |
|
||||
| --------------------------- | ------------------------------------------------------------------------------------------------------ |
|
||||
| `No authentication found` | Source `.records/env/agent-testing-cli.env`, or run device-code `login --server http://localhost:3010` |
|
||||
| `UNAUTHORIZED` on API calls | Re-run `init-dev-env.sh seed-user` and re-source the env file; for device-code fallback, re-run login |
|
||||
| `ECONNREFUSED` | Dev server not running — see dev-server.md |
|
||||
| CLI shows old data/behavior | Server needs restart to pick up code changes |
|
||||
| Login opens wrong server | Must use `--server` flag (env var doesn't work) |
|
||||
| Issue | Solution |
|
||||
| --------------------------- | ----------------------------------------------- |
|
||||
| `No authentication found` | Run `login --server http://localhost:3010` |
|
||||
| `UNAUTHORIZED` on API calls | Token expired; re-run login |
|
||||
| `ECONNREFUSED` | Dev server not running — see dev-server.md |
|
||||
| CLI shows old data/behavior | Server needs restart to pick up code changes |
|
||||
| Login opens wrong server | Must use `--server` flag (env var doesn't work) |
|
||||
|
||||
@@ -1,72 +1,37 @@
|
||||
# Auth Setup for Local Agent Testing
|
||||
|
||||
**Auth is the gate for all automated testing.** Complete
|
||||
[Step 0.0](../SKILL.md#00-resolve-the-current-test-environment) first so
|
||||
`SERVER_URL` and ports are resolved, then verify auth before writing any test
|
||||
step.
|
||||
|
||||
Initialize helpers first:
|
||||
**Auth is the gate for all automated testing.** Prepare and verify it before
|
||||
writing any test step. The one-stop entry point is:
|
||||
|
||||
```bash
|
||||
SCRIPT="./.agents/skills/agent-testing/scripts/setup-auth.sh"
|
||||
TEST_ENV="./.agents/skills/agent-testing/scripts/test-env.sh"
|
||||
eval "$($TEST_ENV --exports)"
|
||||
SCRIPT=".agents/skills/agent-testing/scripts/setup-auth.sh"
|
||||
|
||||
$SCRIPT status # check server + CLI + web auth readiness
|
||||
$SCRIPT cli # interactive CLI device-code login (must be run by the user)
|
||||
pbpaste | $SCRIPT web # inject a copied Cookie header into the agent-browser session
|
||||
$SCRIPT web-verify # live-check that the agent-browser session is authenticated
|
||||
```
|
||||
|
||||
Quick reference after initialization:
|
||||
|
||||
| Command | Purpose |
|
||||
| ------------------------------ | -------------------------------------------------- |
|
||||
| `$SCRIPT status` | Check all surfaces (server + CLI + web + Electron) |
|
||||
| `$SCRIPT status --surface web` | Check only the Web surface gate |
|
||||
| `$SCRIPT cli-seed` | Configure CLI API-key auth from the seeded key |
|
||||
| `$SCRIPT cli` | Interactive CLI device-code login (user must run) |
|
||||
| `$SCRIPT open-chrome` | Open Chrome at `SERVER_URL` with DevTools |
|
||||
| `$SCRIPT web-seed` | Sign in the seeded user and inject cookies |
|
||||
| `pbpaste \| $SCRIPT web` | Inject a copied Cookie header into agent-browser |
|
||||
| `$SCRIPT web-verify` | Live-check agent-browser session auth |
|
||||
|
||||
Use `localhost` for Web auth; better-auth cookies are stored for `localhost`,
|
||||
not `127.0.0.1`.
|
||||
`SERVER_URL` defaults to `http://localhost:3010` (this repo's `dev:next` port).
|
||||
Override it when testing against another server (e.g. `SERVER_URL=http://localhost:3011`
|
||||
in the cloud repo).
|
||||
|
||||
## Per-surface overview
|
||||
|
||||
| Surface | Mechanism | Persistence | Human interaction |
|
||||
| -------- | ---------------------------------------- | ----------------------------------------------------------------- | ---------------------------------------------- |
|
||||
| CLI | Seeded API key or OIDC Device Code Flow | `.records/env/agent-testing-cli.env` + `$HOME/.lobehub-dev` | No for seed path; yes for device-code fallback |
|
||||
| Web | Seeded better-auth login or cookie copy | `~/.lobehub-agent-testing/web-state.json` + agent-browser session | No for seed path; copy cookie only as fallback |
|
||||
| Electron | App's own login state | Electron user-data dir | Log in once manually in the app |
|
||||
| Bot | Native apps (Discord/WeChat/…) logged in | Each app's own session | Once per app |
|
||||
| Surface | Mechanism | Persistence | Human interaction |
|
||||
| -------- | ---------------------------------------- | ----------------------------------------------------------------- | ----------------------------------------------- |
|
||||
| CLI | OIDC Device Code Flow | `apps/cli/.lobehub-dev/settings.json` | Yes — browser authorization, every token expiry |
|
||||
| Web | better-auth cookie injection | `~/.lobehub-agent-testing/web-state.json` + agent-browser session | Copy the Cookie header once per token rotation |
|
||||
| Electron | App's own login state | Electron user-data dir | Log in once manually in the app |
|
||||
| Bot | Native apps (Discord/WeChat/…) logged in | Each app's own session | Once per app |
|
||||
|
||||
## CLI — Seeded API key
|
||||
## CLI — Device Code Flow
|
||||
|
||||
For the self-contained no-root-`.env` dev environment, seed the baseline user
|
||||
and API key once:
|
||||
|
||||
```bash
|
||||
./.agents/skills/agent-testing/scripts/init-dev-env.sh seed-user
|
||||
source .records/env/agent-testing-cli.env
|
||||
./.agents/skills/agent-testing/scripts/setup-auth.sh cli-seed
|
||||
```
|
||||
|
||||
The seed step writes `LOBE_API_KEY` for humans and maps it to the CLI's current
|
||||
auth variable, `LOBEHUB_CLI_API_KEY`. It also sets `LOBEHUB_SERVER` so CLI
|
||||
commands hit the local server without needing a stored device-code token.
|
||||
|
||||
Use this for automated CLI verification:
|
||||
|
||||
```bash
|
||||
cd apps/cli
|
||||
source ../../.records/env/agent-testing-cli.env
|
||||
bun src/index.ts <command>
|
||||
```
|
||||
|
||||
## CLI — Device Code Flow fallback
|
||||
|
||||
Use device-code login only when testing against a non-seeded environment.
|
||||
Credentials are isolated from the user's real CLI config via
|
||||
`LOBEHUB_CLI_HOME=.lobehub-dev`, which the current CLI stores under
|
||||
`$HOME/.lobehub-dev`.
|
||||
`LOBEHUB_CLI_HOME=.lobehub-dev` (kept inside `apps/cli/`, gitignored).
|
||||
|
||||
Login requires interactive browser authorization, so **the user must run it
|
||||
themselves** (e.g. via the `!` prefix in Claude Code):
|
||||
|
||||
```bash
|
||||
cd apps/cli && LOBEHUB_CLI_HOME=.lobehub-dev bun src/index.ts login --server http://localhost:3010
|
||||
@@ -75,30 +40,10 @@ cd apps/cli && LOBEHUB_CLI_HOME=.lobehub-dev bun src/index.ts login --server htt
|
||||
- The `--server` flag is required — an env var does NOT work and login will hit
|
||||
the wrong server without it.
|
||||
- Check state without logging in: `setup-auth.sh status` (verifies
|
||||
`LOBEHUB_CLI_API_KEY` when present, otherwise checks the stored server URL).
|
||||
`settings.json` exists and `serverUrl` matches).
|
||||
- `UNAUTHORIZED` on API calls means the token expired — re-run login.
|
||||
|
||||
## Web — seeded better-auth login
|
||||
|
||||
The Web test surface is `agent-browser --session lobehub-dev`. The user's
|
||||
ordinary Chrome is only a cookie source; Chrome screenshots, Chrome Network
|
||||
records, and Chrome logged-in state do not prove the agent-browser test session
|
||||
is authenticated.
|
||||
|
||||
For the seeded local dev environment, use the automatic path:
|
||||
|
||||
```bash
|
||||
./.agents/skills/agent-testing/scripts/init-dev-env.sh seed-user
|
||||
./.agents/skills/agent-testing/scripts/setup-auth.sh web-seed
|
||||
```
|
||||
|
||||
`web-seed` posts the seeded email/password to
|
||||
`/api/auth/sign-in/email`, stores the returned cookie jar under
|
||||
`~/.lobehub-agent-testing/`, converts it to Playwright `storageState`, loads it
|
||||
into the `agent-browser` session, and verifies the session does not land on
|
||||
`/signin`.
|
||||
|
||||
## Web — manual cookie injection fallback
|
||||
## Web — better-auth cookie injection (agent-browser)
|
||||
|
||||
`agent-browser --headed` on macOS often creates the Chromium window off-screen —
|
||||
the user can't see or interact with it, so manual login inside the agent-browser
|
||||
@@ -108,19 +53,31 @@ user's own logged-in Chrome and inject it as a Playwright-style state file.
|
||||
Do **not** use this on production URLs — only local dev. Treat the cookie as a
|
||||
secret: don't paste it into shared logs, PRs, or commit it anywhere.
|
||||
|
||||
### Web — decision flow
|
||||
### One-key path
|
||||
|
||||
1. `$SCRIPT status --surface web` — green? Start testing. Do not ask for a Cookie header.
|
||||
2. Not green and using the seeded local env → `$SCRIPT web-seed`.
|
||||
3. Still not green or not using the seed env → `$SCRIPT open-chrome` opens Chrome at `SERVER_URL` with DevTools.
|
||||
4. User copies the `Cookie:` header from Network tab → any same-origin request → Request Headers → right-click `Cookie:` → **Copy value**. Must be from Network, NOT `document.cookie` (HttpOnly cookies are invisible to `document.cookie`).
|
||||
5. `pbpaste | $SCRIPT web` — filters to better-auth cookies (`session_token`, `session_data`, `state`), builds Playwright `storageState`, loads it into the `agent-browser` session (`lobehub-dev`), opens `SERVER_URL`, and asserts the URL is not `/signin`.
|
||||
1. Ask the user to copy the Cookie header **from a Network request, NOT
|
||||
`document.cookie`** (`document.cookie` cannot see HttpOnly cookies, which is
|
||||
exactly where better-auth puts its session):
|
||||
- Open the logged-in tab (`http://localhost:<port>/…`) in Chrome.
|
||||
- `Cmd+Option+I` → **Network** tab → refresh → click any same-origin request.
|
||||
- Under **Request Headers**, right-click the `Cookie:` line → **Copy value**.
|
||||
2. Inject and verify in one shot:
|
||||
|
||||
```bash
|
||||
pbpaste | ./.agents/skills/agent-testing/scripts/setup-auth.sh web
|
||||
```
|
||||
|
||||
The script filters the header down to the better-auth cookies
|
||||
(`better-auth.session_token`, `better-auth.state`), builds the Playwright
|
||||
`storageState` JSON, loads it into the `agent-browser` session (default name
|
||||
`lobehub-dev`), opens `SERVER_URL`, and asserts the URL is not `/signin`.
|
||||
|
||||
### Using the authenticated session
|
||||
|
||||
```bash
|
||||
agent-browser --session lobehub-dev open "$SERVER_URL/"
|
||||
agent-browser --session lobehub-dev open "http://localhost:3010/"
|
||||
agent-browser --session lobehub-dev snapshot -i | head -20
|
||||
# Look for the user's avatar/name in the sidebar, or absence of the signin form.
|
||||
```
|
||||
|
||||
### Notes
|
||||
@@ -133,12 +90,12 @@ agent-browser --session lobehub-dev snapshot -i | head -20
|
||||
|
||||
### Common failure modes
|
||||
|
||||
| Symptom | Cause | Fix |
|
||||
| --------------------------------------------- | ------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------- |
|
||||
| Still redirects to `/signin` after injection | User pasted from `document.cookie` → missed HttpOnly session | Re-pull from Network request Headers, not console |
|
||||
| Script reports `no better-auth cookies found` | User pasted the wrong value, or the cookie parser regressed | Keep the raw `Cookie:` header as-is; run `scripts/setup-auth.test.sh` if the input looks valid |
|
||||
| Login works briefly then expires | `better-auth.session_token` rotated (user logged out / signed in again) | Re-copy and re-inject |
|
||||
| Domain mismatch | Cookie domain must be `localhost` literally, no leading dot for local dev | — |
|
||||
| Symptom | Cause | Fix |
|
||||
| --------------------------------------------- | ------------------------------------------------------------------------- | ------------------------------------------------- |
|
||||
| Still redirects to `/signin` after injection | User pasted from `document.cookie` → missed HttpOnly session | Re-pull from Network request Headers, not console |
|
||||
| Script reports `no better-auth cookies found` | Separator wrong, or user pasted URL-decoded value | Keep the raw `Cookie:` header as-is |
|
||||
| Login works briefly then expires | `better-auth.session_token` rotated (user logged out / signed in again) | Re-copy and re-inject |
|
||||
| Domain mismatch | Cookie domain must be `localhost` literally, no leading dot for local dev | — |
|
||||
|
||||
## Electron
|
||||
|
||||
|
||||
@@ -3,44 +3,22 @@
|
||||
Single source of truth for starting / restarting the backend that all test
|
||||
surfaces (CLI, Electron, Web) hit.
|
||||
|
||||
## Resolve ports first
|
||||
|
||||
Run `test-env.sh` as described in
|
||||
[SKILL.md Step 0.0](../SKILL.md#00-resolve-the-current-test-environment)
|
||||
before starting or probing any local test surface.
|
||||
|
||||
## Ports & modes
|
||||
|
||||
| Command | What it runs | Port source |
|
||||
| ------------------- | --------------------------------------------------------- | ------------------- |
|
||||
| `pnpm run dev:next` | Next.js backend (API + auth) | `PORT` |
|
||||
| `bun run dev` | Full-stack (Next.js + Vite SPA, via `devStartupSequence`) | `PORT` + `SPA_PORT` |
|
||||
| `bun run dev:spa` | Vite SPA only, proxies API to `PORT` | `SPA_PORT` |
|
||||
| Command | What it runs | Port |
|
||||
| ------------------- | --------------------------------------------------------- | --------------------------------- |
|
||||
| `pnpm run dev:next` | Next.js backend (API + auth) | `3010` |
|
||||
| `bun run dev` | Full-stack (Next.js + Vite SPA, via `devStartupSequence`) | `3010` (API) + SPA on `9876` |
|
||||
| `bun run dev:spa` | Vite SPA only, proxies API to `3010` | `9876` (prints a Debug Proxy URL) |
|
||||
|
||||
In the **cloud repo** (where this repo is the `lobehub/` submodule), local
|
||||
worktree names map to fallback defaults only when `.env` and shell env do not
|
||||
provide values:
|
||||
|
||||
| Workspace directory | Default `SERVER_URL` |
|
||||
| ------------------- | -------------------------------- |
|
||||
| `lobehub` | `http://localhost:3010` |
|
||||
| `lobehub-cloud` | `http://localhost:3020` |
|
||||
| `lobehub-cloud-1` | `http://localhost:3021` |
|
||||
| `lobehub-cloud-N` | `http://localhost:$((3020 + N))` |
|
||||
|
||||
`test-env.sh` and `setup-auth.sh` both use the resolved env first and these
|
||||
worktree defaults only as fallback. Treat the dev-server terminal output as the
|
||||
final source of truth when testing a non-standard port, then export it for every
|
||||
agent-testing command:
|
||||
|
||||
```bash
|
||||
export SERVER_URL=http://localhost:<port-from-dev-output>
|
||||
```
|
||||
In the **cloud repo** (where this repo is the `lobehub/` submodule) the dev
|
||||
server conventionally runs on `3011` — set `SERVER_URL=http://localhost:3011`
|
||||
for the scripts in this skill when testing there.
|
||||
|
||||
## Health check
|
||||
|
||||
```bash
|
||||
curl -s -o /dev/null -w '%{http_code}' "$SERVER_URL/"
|
||||
curl -s -o /dev/null -w '%{http_code}' http://localhost:3010/
|
||||
```
|
||||
|
||||
## Start / restart
|
||||
@@ -60,11 +38,8 @@ bun run dev
|
||||
# Without root .env:
|
||||
./.agents/skills/agent-testing/scripts/init-dev-env.sh dev
|
||||
|
||||
# Local QStash. Run in a separate terminal only when testing workflow paths.
|
||||
./.agents/skills/agent-testing/scripts/init-dev-env.sh qstash
|
||||
|
||||
# Restart — required to pick up server-side code changes
|
||||
lsof -ti:"$PORT" | xargs kill
|
||||
lsof -ti:3010 | xargs kill
|
||||
pnpm run dev:next
|
||||
# or, when no root .env exists:
|
||||
# ./.agents/skills/agent-testing/scripts/init-dev-env.sh dev-next
|
||||
@@ -86,13 +61,8 @@ in doubt.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
| Issue | Solution |
|
||||
| ------------------------- | --------------------------------------------------------------------------------------------- |
|
||||
| `ECONNREFUSED` | Server not running — start it |
|
||||
| `EADDRINUSE` on the port | Already running — `lsof -ti:<port> \| xargs kill` first |
|
||||
| Stale data / old behavior | Server needs a restart to pick up code changes |
|
||||
| QStash workflow failures | Start `init-dev-env.sh qstash` and make sure dev server inherited the script's `QSTASH_*` env |
|
||||
|
||||
Marketplace/community endpoints are not part of the local agent-testing auth
|
||||
gate. Do not block local product-chain verification on marketplace API auth
|
||||
unless the change explicitly targets marketplace behavior.
|
||||
| Issue | Solution |
|
||||
| ------------------------- | ------------------------------------------------------- |
|
||||
| `ECONNREFUSED` | Server not running — start it |
|
||||
| `EADDRINUSE` on the port | Already running — `lsof -ti:<port> \| xargs kill` first |
|
||||
| Stale data / old behavior | Server needs a restart to pick up code changes |
|
||||
|
||||
@@ -64,10 +64,7 @@ output):
|
||||
not acceptable as primary visual evidence.
|
||||
|
||||
4. **Set the verdict** in both `report.md` and `result.json`, then link the
|
||||
report directory in your final answer to the user. If UI evidence exists,
|
||||
list the key screenshot/GIF links in the final chat response. Use Markdown
|
||||
link text as the evidence caption, for example:
|
||||
`[Image #1 - observed outcome](<report-dir>/assets/case1.png)`.
|
||||
report directory in your final answer to the user.
|
||||
|
||||
## Report language (hard rule)
|
||||
|
||||
|
||||
@@ -14,14 +14,13 @@
|
||||
# init-dev-env.sh write [file] # write a source-able env file
|
||||
# init-dev-env.sh setup-db # start local Postgres and run migrations
|
||||
# init-dev-env.sh migrate # run DB migrations against the configured DB
|
||||
# init-dev-env.sh seed-user # seed the baseline test user + CLI API key
|
||||
# init-dev-env.sh qstash # run local Upstash QStash dev server
|
||||
# init-dev-env.sh seed-user # seed the baseline test user
|
||||
# init-dev-env.sh dev-next # exec `pnpm run dev:next` with this env
|
||||
# init-dev-env.sh dev # exec `bun run dev` with this env
|
||||
# init-dev-env.sh clean-db # remove the managed Postgres container
|
||||
#
|
||||
# Overrides:
|
||||
# SERVER_PORT=3010 DB_PORT=5433 DB_CONTAINER=lobehub-agent-testing-postgres QSTASH_DEV_PORT=8080
|
||||
# SERVER_PORT=3010 DB_PORT=5433 DB_CONTAINER=lobehub-agent-testing-postgres
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
@@ -33,12 +32,6 @@ DB_PORT="${DB_PORT:-5433}"
|
||||
DB_CONTAINER="${DB_CONTAINER:-lobehub-agent-testing-postgres}"
|
||||
DATABASE_URL="${DATABASE_URL:-postgresql://postgres:postgres@localhost:${DB_PORT}/postgres}"
|
||||
ENV_FILE_DEFAULT="$REPO_ROOT/.records/env/agent-testing-dev.env"
|
||||
CLI_ENV_FILE_DEFAULT="$REPO_ROOT/.records/env/agent-testing-cli.env"
|
||||
AGENT_TESTING_API_KEY="${AGENT_TESTING_API_KEY:-sk-lh-agenttesting0001}"
|
||||
QSTASH_DEV_PORT="${QSTASH_DEV_PORT:-8080}"
|
||||
QSTASH_LOCAL_TOKEN="${QSTASH_LOCAL_TOKEN:-eyJVc2VySUQiOiJkZWZhdWx0VXNlciIsIlBhc3N3b3JkIjoiZGVmYXVsdFBhc3N3b3JkIn0=}"
|
||||
QSTASH_LOCAL_CURRENT_SIGNING_KEY="${QSTASH_LOCAL_CURRENT_SIGNING_KEY:-sig_7kYjw48mhY7kAjqNGcy6cr29RJ6r}"
|
||||
QSTASH_LOCAL_NEXT_SIGNING_KEY="${QSTASH_LOCAL_NEXT_SIGNING_KEY:-sig_5ZB6DVzB1wjE8S6rZ7eenA8Pdnhs}"
|
||||
|
||||
ok() { printf ' \033[32m✔\033[0m %s\n' "$1"; }
|
||||
bad() { printf ' \033[31m✘\033[0m %s\n' "$1"; }
|
||||
@@ -64,11 +57,6 @@ apply_env() {
|
||||
export NEXT_PUBLIC_AUTH_EMAIL_VERIFICATION="${NEXT_PUBLIC_AUTH_EMAIL_VERIFICATION:-0}"
|
||||
export NODE_OPTIONS="${NODE_OPTIONS:---max-old-space-size=6144}"
|
||||
export PORT="${PORT:-$SERVER_PORT}"
|
||||
export QSTASH_CURRENT_SIGNING_KEY="${QSTASH_CURRENT_SIGNING_KEY:-$QSTASH_LOCAL_CURRENT_SIGNING_KEY}"
|
||||
export QSTASH_DEV_PORT
|
||||
export QSTASH_NEXT_SIGNING_KEY="${QSTASH_NEXT_SIGNING_KEY:-$QSTASH_LOCAL_NEXT_SIGNING_KEY}"
|
||||
export QSTASH_TOKEN="${QSTASH_TOKEN:-$QSTASH_LOCAL_TOKEN}"
|
||||
export QSTASH_URL="${QSTASH_URL:-http://127.0.0.1:${QSTASH_DEV_PORT}}"
|
||||
export S3_ACCESS_KEY_ID="${S3_ACCESS_KEY_ID:-agent-testing-access-key}"
|
||||
export S3_BUCKET="${S3_BUCKET:-agent-testing-bucket}"
|
||||
export S3_ENDPOINT="${S3_ENDPOINT:-https://agent-testing-s3.localhost}"
|
||||
@@ -87,11 +75,6 @@ env_keys() {
|
||||
NEXT_PUBLIC_AUTH_EMAIL_VERIFICATION \
|
||||
NODE_OPTIONS \
|
||||
PORT \
|
||||
QSTASH_CURRENT_SIGNING_KEY \
|
||||
QSTASH_DEV_PORT \
|
||||
QSTASH_NEXT_SIGNING_KEY \
|
||||
QSTASH_TOKEN \
|
||||
QSTASH_URL \
|
||||
S3_ACCESS_KEY_ID \
|
||||
S3_BUCKET \
|
||||
S3_ENDPOINT \
|
||||
@@ -165,14 +148,9 @@ migrate_db() {
|
||||
|
||||
seed_user() {
|
||||
apply_env
|
||||
export AGENT_TESTING_API_KEY
|
||||
export AGENT_TESTING_CLI_ENV_FILE="${AGENT_TESTING_CLI_ENV_FILE:-$CLI_ENV_FILE_DEFAULT}"
|
||||
cd "$REPO_ROOT"
|
||||
node <<'NODE'
|
||||
const bcrypt = require('bcryptjs');
|
||||
const crypto = require('node:crypto');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const pg = require('pg');
|
||||
|
||||
const databaseUrl = process.env.DATABASE_URL;
|
||||
@@ -188,72 +166,13 @@ const TEST_USER = {
|
||||
username: 'agent_testing_user',
|
||||
};
|
||||
|
||||
const TEST_API_KEY = {
|
||||
id: 'api_key_agent_testing_001',
|
||||
key: process.env.AGENT_TESTING_API_KEY || 'sk-lh-agenttesting0001',
|
||||
name: 'Agent Testing CLI API Key',
|
||||
};
|
||||
|
||||
const validateApiKeyFormat = (apiKey) => /^sk-lh-[\da-z]{16}$/.test(apiKey);
|
||||
|
||||
const hashApiKey = (apiKey) => {
|
||||
const secret = process.env.KEY_VAULTS_SECRET;
|
||||
if (!secret) throw new Error('KEY_VAULTS_SECRET is required to seed the baseline API key.');
|
||||
|
||||
return crypto.createHmac('sha256', secret).update(apiKey).digest('hex');
|
||||
};
|
||||
|
||||
const encryptWithKeyVaultsSecret = (plaintext) => {
|
||||
const secret = process.env.KEY_VAULTS_SECRET;
|
||||
if (!secret) throw new Error('KEY_VAULTS_SECRET is required to seed the baseline API key.');
|
||||
|
||||
const rawKey = Buffer.from(secret, 'base64');
|
||||
if (![16, 24, 32].includes(rawKey.length)) {
|
||||
throw new Error(
|
||||
`KEY_VAULTS_SECRET must decode to 16, 24, or 32 bytes, got ${rawKey.length} bytes.`,
|
||||
);
|
||||
}
|
||||
|
||||
const iv = crypto.randomBytes(12);
|
||||
const cipher = crypto.createCipheriv(`aes-${rawKey.length * 8}-gcm`, rawKey, iv);
|
||||
const encrypted = Buffer.concat([cipher.update(plaintext, 'utf8'), cipher.final()]);
|
||||
const authTag = cipher.getAuthTag();
|
||||
|
||||
return `${iv.toString('hex')}:${authTag.toString('hex')}:${encrypted.toString('hex')}`;
|
||||
};
|
||||
|
||||
const writeCliEnvFile = () => {
|
||||
const file = process.env.AGENT_TESTING_CLI_ENV_FILE || '.records/env/agent-testing-cli.env';
|
||||
fs.mkdirSync(path.dirname(file), { recursive: true });
|
||||
fs.writeFileSync(
|
||||
file,
|
||||
[
|
||||
'# Source this file before running LobeHub CLI agent tests.',
|
||||
'# Generated by init-dev-env.sh seed-user',
|
||||
`export LOBE_API_KEY=${TEST_API_KEY.key}`,
|
||||
`export LOBEHUB_CLI_API_KEY="${'${LOBE_API_KEY}'}"`,
|
||||
`export LOBEHUB_SERVER=${process.env.APP_URL}`,
|
||||
'export LOBEHUB_CLI_HOME=.lobehub-dev',
|
||||
'',
|
||||
].join('\n'),
|
||||
);
|
||||
|
||||
return file;
|
||||
};
|
||||
|
||||
const client = new pg.Client({ connectionString: databaseUrl });
|
||||
|
||||
(async () => {
|
||||
if (!validateApiKeyFormat(TEST_API_KEY.key)) {
|
||||
throw new Error(`Invalid AGENT_TESTING_API_KEY format: ${TEST_API_KEY.key}`);
|
||||
}
|
||||
|
||||
await client.connect();
|
||||
const now = new Date().toISOString();
|
||||
const onboarding = JSON.stringify({ finishedAt: now, version: 1 });
|
||||
const passwordHash = await bcrypt.hash(TEST_USER.password, 10);
|
||||
const encryptedApiKey = encryptWithKeyVaultsSecret(TEST_API_KEY.key);
|
||||
const apiKeyHash = hashApiKey(TEST_API_KEY.key);
|
||||
|
||||
await client.query(
|
||||
`INSERT INTO users (id, email, normalized_email, username, full_name, email_verified, onboarding, created_at, updated_at, last_active_at)
|
||||
@@ -285,35 +204,9 @@ const client = new pg.Client({ connectionString: databaseUrl });
|
||||
],
|
||||
);
|
||||
|
||||
await client.query(
|
||||
`INSERT INTO api_keys (id, name, key, key_hash, enabled, expires_at, user_id, workspace_id, created_at, updated_at)
|
||||
VALUES ($1, $2, $3, $4, $5, NULL, $6, NULL, $7, $7)
|
||||
ON CONFLICT (id) DO UPDATE
|
||||
SET name = EXCLUDED.name,
|
||||
key = EXCLUDED.key,
|
||||
key_hash = EXCLUDED.key_hash,
|
||||
enabled = EXCLUDED.enabled,
|
||||
expires_at = NULL,
|
||||
updated_at = EXCLUDED.updated_at`,
|
||||
[
|
||||
TEST_API_KEY.id,
|
||||
TEST_API_KEY.name,
|
||||
encryptedApiKey,
|
||||
apiKeyHash,
|
||||
true,
|
||||
TEST_USER.id,
|
||||
now,
|
||||
],
|
||||
);
|
||||
|
||||
const cliEnvFile = writeCliEnvFile();
|
||||
|
||||
console.log('seeded baseline user:');
|
||||
console.log(` email: ${TEST_USER.email}`);
|
||||
console.log(` password: ${TEST_USER.password}`);
|
||||
console.log('seeded baseline API key:');
|
||||
console.log(` LOBE_API_KEY: ${TEST_API_KEY.key}`);
|
||||
console.log(` CLI env: ${cliEnvFile}`);
|
||||
})()
|
||||
.finally(() => client.end())
|
||||
.catch((error) => {
|
||||
@@ -329,7 +222,6 @@ cmd_status() {
|
||||
note "APP_URL=$APP_URL"
|
||||
note "DATABASE_URL=$DATABASE_URL"
|
||||
note "PORT=$PORT"
|
||||
note "QSTASH_URL=$QSTASH_URL"
|
||||
if command -v docker > /dev/null 2>&1; then
|
||||
ok "docker CLI available"
|
||||
if docker ps --format '{{.Names}}' | grep -Fxq "$DB_CONTAINER"; then
|
||||
@@ -342,14 +234,6 @@ cmd_status() {
|
||||
fi
|
||||
}
|
||||
|
||||
cmd_qstash() {
|
||||
apply_env
|
||||
cd "$REPO_ROOT"
|
||||
note "starting local QStash dev server at $QSTASH_URL"
|
||||
note "keep this process running while testing workflow paths"
|
||||
exec pnpm run qstash -- -port "$QSTASH_DEV_PORT"
|
||||
}
|
||||
|
||||
cmd_dev_next() {
|
||||
apply_env
|
||||
cd "$REPO_ROOT"
|
||||
@@ -395,7 +279,6 @@ case "$COMMAND" in
|
||||
;;
|
||||
migrate) migrate_db ;;
|
||||
seed-user) seed_user ;;
|
||||
qstash) cmd_qstash ;;
|
||||
dev-next) cmd_dev_next ;;
|
||||
dev) cmd_dev ;;
|
||||
clean-db) cmd_clean_db ;;
|
||||
|
||||
@@ -5,114 +5,29 @@
|
||||
# test step. Background and failure modes: ../references/auth.md
|
||||
#
|
||||
# Usage:
|
||||
# setup-auth.sh status # check server + CLI + web + Electron readiness
|
||||
# setup-auth.sh status --surface web # check only the Web surface gate
|
||||
# setup-auth.sh cli-seed # configure CLI API-key auth from seeded local env
|
||||
# setup-auth.sh status # check server + CLI + web auth readiness
|
||||
# setup-auth.sh cli # interactive CLI device-code login (run by a human)
|
||||
# setup-auth.sh open-chrome # open SERVER_URL in Chrome and show DevTools
|
||||
# setup-auth.sh web-seed # sign in seeded user and inject cookies automatically
|
||||
# setup-auth.sh web # stdin = Cookie header -> inject into agent-browser session
|
||||
# setup-auth.sh web-verify # live-check the agent-browser session is authenticated
|
||||
#
|
||||
# Env:
|
||||
# SERVER_URL (default from test-env.sh) dev server under test
|
||||
# SERVER_URL (default http://localhost:3010) dev server under test
|
||||
# SESSION (default lobehub-dev) agent-browser session name
|
||||
# AUTH_DIR (default ~/.lobehub-agent-testing) where web state is persisted
|
||||
# SEED_EMAIL / SEED_PASSWORD seeded better-auth login
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../../../.." && pwd)"
|
||||
|
||||
workspace_root_for_port() {
|
||||
local root="$REPO_ROOT"
|
||||
local name
|
||||
name="$(basename "$root")"
|
||||
|
||||
if [[ "$name" == "lobehub" ]]; then
|
||||
local parent
|
||||
parent="$(cd "$root/.." && pwd)"
|
||||
local parent_name
|
||||
parent_name="$(basename "$parent")"
|
||||
if [[ "$parent_name" == lobehub-cloud* ]]; then
|
||||
root="$parent"
|
||||
fi
|
||||
fi
|
||||
|
||||
printf '%s\n' "$root"
|
||||
}
|
||||
|
||||
default_server_url() {
|
||||
local env_resolver resolved
|
||||
env_resolver="$(dirname "${BASH_SOURCE[0]}")/test-env.sh"
|
||||
if [[ -x "$env_resolver" ]]; then
|
||||
resolved="$("$env_resolver" --value SERVER_URL 2> /dev/null || true)"
|
||||
if [[ -n "$resolved" ]]; then
|
||||
printf '%s\n' "$resolved"
|
||||
return 0
|
||||
fi
|
||||
fi
|
||||
|
||||
local root name suffix port
|
||||
root="$(workspace_root_for_port)"
|
||||
name="$(basename "$root")"
|
||||
|
||||
case "$name" in
|
||||
lobehub-cloud)
|
||||
port=3020
|
||||
;;
|
||||
lobehub-cloud-*)
|
||||
suffix="${name#lobehub-cloud-}"
|
||||
if [[ "$suffix" =~ ^[0-9]+$ ]]; then
|
||||
port=$((3020 + 10#$suffix))
|
||||
else
|
||||
port=3010
|
||||
fi
|
||||
;;
|
||||
*)
|
||||
port=3010
|
||||
;;
|
||||
esac
|
||||
|
||||
printf 'http://localhost:%s\n' "$port"
|
||||
}
|
||||
|
||||
SERVER_URL="${SERVER_URL:-$(default_server_url)}"
|
||||
SERVER_URL="${SERVER_URL:-http://localhost:3010}"
|
||||
SESSION="${SESSION:-lobehub-dev}"
|
||||
AUTH_DIR="${AUTH_DIR:-$HOME/.lobehub-agent-testing}"
|
||||
STATE_FILE="$AUTH_DIR/web-state.json"
|
||||
CLI_HOME_NAME="${LOBEHUB_CLI_HOME:-.lobehub-dev}"
|
||||
CLI_HOME="$HOME/${CLI_HOME_NAME#/}"
|
||||
CLI_CREDENTIALS_FILE="$CLI_HOME/credentials.json"
|
||||
SEED_EMAIL="${SEED_EMAIL:-agent-testing@lobehub.com}"
|
||||
SEED_PASSWORD="${SEED_PASSWORD:-TestPassword123!}"
|
||||
SEED_API_KEY="${SEED_API_KEY:-${AGENT_TESTING_API_KEY:-sk-lh-agenttesting0001}}"
|
||||
CLI_ENV_FILE="${CLI_ENV_FILE:-$REPO_ROOT/.records/env/agent-testing-cli.env}"
|
||||
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../../../.." && pwd)"
|
||||
CLI_HOME="$REPO_ROOT/apps/cli/.lobehub-dev"
|
||||
|
||||
ok() { printf ' \033[32m✔\033[0m %s\n' "$1"; }
|
||||
bad() { printf ' \033[31m✘\033[0m %s\n' "$1"; }
|
||||
note() { printf ' %s\n' "$1"; }
|
||||
|
||||
usage() {
|
||||
cat << EOF
|
||||
Usage:
|
||||
$0 status [--surface all|cli|web|electron]
|
||||
$0 cli-seed
|
||||
$0 cli
|
||||
$0 open-chrome [--dry-run]
|
||||
$0 web-seed
|
||||
$0 web
|
||||
$0 web-verify
|
||||
|
||||
Env:
|
||||
SERVER_URL=$SERVER_URL
|
||||
SESSION=$SESSION
|
||||
AUTH_DIR=$AUTH_DIR
|
||||
SEED_EMAIL=$SEED_EMAIL
|
||||
CLI_HOME=$CLI_HOME
|
||||
EOF
|
||||
}
|
||||
|
||||
check_server() {
|
||||
local code
|
||||
code=$(curl -s -o /dev/null -w '%{http_code}' "$SERVER_URL/" 2> /dev/null || true)
|
||||
@@ -126,35 +41,11 @@ check_server() {
|
||||
}
|
||||
|
||||
check_cli() {
|
||||
local api_key="${LOBEHUB_CLI_API_KEY:-${LOBE_API_KEY:-}}"
|
||||
if [[ -n "$api_key" ]]; then
|
||||
local body_file code
|
||||
body_file="$(mktemp)"
|
||||
code=$(curl -sS -o "$body_file" -w '%{http_code}' \
|
||||
-H "Authorization: Bearer $api_key" \
|
||||
"$SERVER_URL/api/v1/users/me?includeCount=0" 2> /dev/null || true)
|
||||
|
||||
if [[ "$code" =~ ^[23] ]]; then
|
||||
rm -f "$body_file"
|
||||
ok "CLI API-key auth valid for $SERVER_URL"
|
||||
return 0
|
||||
fi
|
||||
|
||||
bad "CLI API-key auth failed for $SERVER_URL (http_code='$code')"
|
||||
note "seed the local API key first:"
|
||||
note "./.agents/skills/agent-testing/scripts/init-dev-env.sh seed-user"
|
||||
note "source $CLI_ENV_FILE"
|
||||
rm -f "$body_file"
|
||||
return 1
|
||||
fi
|
||||
|
||||
if [[ -f "$CLI_HOME/settings.json" ]] && grep -q "$SERVER_URL" "$CLI_HOME/settings.json" && [[ -f "$CLI_CREDENTIALS_FILE" ]]; then
|
||||
ok "CLI device-code credentials configured for $SERVER_URL (creds: $CLI_HOME)"
|
||||
if [[ -f "$CLI_HOME/settings.json" ]] && grep -q "$SERVER_URL" "$CLI_HOME/settings.json"; then
|
||||
ok "CLI logged in to $SERVER_URL (creds: apps/cli/.lobehub-dev)"
|
||||
else
|
||||
bad "CLI not logged in to $SERVER_URL"
|
||||
note "automated path:"
|
||||
note "./.agents/skills/agent-testing/scripts/init-dev-env.sh seed-user && source $CLI_ENV_FILE && $0 cli-seed"
|
||||
note "interactive fallback:"
|
||||
note "ask the user to run:"
|
||||
note "cd apps/cli && LOBEHUB_CLI_HOME=.lobehub-dev bun src/index.ts login --server $SERVER_URL"
|
||||
return 1
|
||||
fi
|
||||
@@ -163,24 +54,13 @@ check_cli() {
|
||||
check_web() {
|
||||
if [[ -f "$STATE_FILE" ]]; then
|
||||
ok "web auth state saved ($STATE_FILE)"
|
||||
note "live-verify: $0 web-verify"
|
||||
else
|
||||
bad "no web auth state for agent-browser"
|
||||
note "for the seeded local user, run: $0 web-seed"
|
||||
note "or copy the Cookie header from Chrome DevTools (Network tab), then:"
|
||||
note "copy the Cookie header from Chrome DevTools (Network tab), then:"
|
||||
note "pbpaste | $0 web (see references/auth.md)"
|
||||
return 1
|
||||
fi
|
||||
cmd_web_verify --skip-server-check
|
||||
}
|
||||
|
||||
check_agent_browser() {
|
||||
if command -v agent-browser > /dev/null 2>&1; then
|
||||
ok "agent-browser available"
|
||||
else
|
||||
bad "agent-browser command not found"
|
||||
note "install or expose agent-browser before Web/Electron UI testing"
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
|
||||
check_electron() {
|
||||
@@ -204,75 +84,16 @@ check_electron() {
|
||||
}
|
||||
|
||||
cmd_status() {
|
||||
local surface="all"
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--surface)
|
||||
if [[ $# -lt 2 ]]; then
|
||||
echo "--surface requires one of: all, cli, web, electron" >&2
|
||||
return 2
|
||||
fi
|
||||
surface="${2:-}"
|
||||
shift 2
|
||||
;;
|
||||
--surface=*)
|
||||
surface="${1#*=}"
|
||||
shift
|
||||
;;
|
||||
all|cli|web|electron)
|
||||
surface="$1"
|
||||
shift
|
||||
;;
|
||||
-h|--help)
|
||||
usage
|
||||
return 0
|
||||
;;
|
||||
*)
|
||||
echo "unknown status option: $1" >&2
|
||||
usage >&2
|
||||
return 2
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
case "$surface" in
|
||||
all|cli|web|electron) ;;
|
||||
"")
|
||||
echo "--surface requires one of: all, cli, web, electron" >&2
|
||||
return 2
|
||||
;;
|
||||
*)
|
||||
echo "unknown surface: $surface" >&2
|
||||
usage >&2
|
||||
return 2
|
||||
;;
|
||||
esac
|
||||
|
||||
echo "agent-testing auth status (surface=$surface, SERVER_URL=$SERVER_URL):"
|
||||
echo "agent-testing auth status (SERVER_URL=$SERVER_URL):"
|
||||
local rc=0
|
||||
case "$surface" in
|
||||
all)
|
||||
check_server || rc=1
|
||||
check_cli || rc=1
|
||||
check_web || rc=1
|
||||
check_electron || rc=1
|
||||
;;
|
||||
cli)
|
||||
check_server || rc=1
|
||||
check_cli || rc=1
|
||||
;;
|
||||
web)
|
||||
check_server || rc=1
|
||||
check_web || rc=1
|
||||
;;
|
||||
electron)
|
||||
check_electron || rc=1
|
||||
;;
|
||||
esac
|
||||
check_server || rc=1
|
||||
check_cli || rc=1
|
||||
check_web || rc=1
|
||||
check_electron || rc=1
|
||||
if [[ $rc -eq 0 ]]; then
|
||||
echo "$surface auth green — safe to start automated testing on this surface."
|
||||
echo "all green — safe to start automated testing."
|
||||
else
|
||||
echo "$surface auth NOT ready — fix the ✘ items before writing any test step."
|
||||
echo "auth NOT ready — fix the ✘ items before writing any test step."
|
||||
fi
|
||||
return $rc
|
||||
}
|
||||
@@ -284,148 +105,23 @@ cmd_cli() {
|
||||
LOBEHUB_CLI_HOME=.lobehub-dev bun src/index.ts login --server "$SERVER_URL"
|
||||
}
|
||||
|
||||
write_cli_seed_env() {
|
||||
mkdir -p "$(dirname "$CLI_ENV_FILE")"
|
||||
cat > "$CLI_ENV_FILE" << EOF
|
||||
# Source this file before running LobeHub CLI agent tests.
|
||||
# Generated by setup-auth.sh cli-seed
|
||||
export LOBE_API_KEY=$SEED_API_KEY
|
||||
export LOBEHUB_CLI_API_KEY="\${LOBE_API_KEY}"
|
||||
export LOBEHUB_SERVER=$SERVER_URL
|
||||
export LOBEHUB_CLI_HOME=.lobehub-dev
|
||||
EOF
|
||||
}
|
||||
|
||||
write_cli_settings() {
|
||||
mkdir -p "$CLI_HOME"
|
||||
python3 - "$CLI_HOME/settings.json" "$SERVER_URL" << 'PY'
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
|
||||
path, server_url = sys.argv[1], sys.argv[2]
|
||||
os.makedirs(os.path.dirname(path), exist_ok=True)
|
||||
with open(path, "w") as f:
|
||||
json.dump({"serverUrl": server_url}, f, indent=2)
|
||||
f.write("\n")
|
||||
os.chmod(path, 0o600)
|
||||
PY
|
||||
}
|
||||
|
||||
cmd_cli_seed() {
|
||||
check_server || return 1
|
||||
write_cli_seed_env
|
||||
write_cli_settings
|
||||
ok "wrote CLI seed env: $CLI_ENV_FILE"
|
||||
note "source it before CLI commands: source $CLI_ENV_FILE"
|
||||
note "settings saved at: $CLI_HOME/settings.json"
|
||||
LOBE_API_KEY="$SEED_API_KEY" LOBEHUB_CLI_API_KEY="$SEED_API_KEY" check_cli
|
||||
}
|
||||
|
||||
cmd_open_chrome() {
|
||||
local mode="${1:-}"
|
||||
if [[ "$mode" != "" && "$mode" != "--dry-run" ]]; then
|
||||
echo "unknown open-chrome option: $mode" >&2
|
||||
usage >&2
|
||||
return 2
|
||||
fi
|
||||
|
||||
if [[ "$mode" == "--dry-run" ]]; then
|
||||
echo "would open Google Chrome at $SERVER_URL/"
|
||||
echo "would press Cmd+Option+I to open DevTools"
|
||||
echo "would open DevTools command menu and run 'Show Network'"
|
||||
return 0
|
||||
fi
|
||||
|
||||
if [[ "$(uname -s)" != "Darwin" ]]; then
|
||||
bad "open-chrome is macOS-only"
|
||||
note "open $SERVER_URL/ in your browser and open DevTools manually"
|
||||
return 1
|
||||
fi
|
||||
|
||||
if ! command -v osascript > /dev/null 2>&1; then
|
||||
bad "osascript not found"
|
||||
note "open $SERVER_URL/ in Chrome and press Cmd+Option+I manually"
|
||||
return 1
|
||||
fi
|
||||
|
||||
SERVER_URL="$SERVER_URL" osascript << 'OSA'
|
||||
set targetUrl to (system attribute "SERVER_URL") & "/"
|
||||
|
||||
tell application "Google Chrome"
|
||||
activate
|
||||
if (count of windows) = 0 then
|
||||
make new window
|
||||
end if
|
||||
tell front window to make new tab with properties {URL:targetUrl}
|
||||
end tell
|
||||
|
||||
delay 1
|
||||
|
||||
tell application "System Events"
|
||||
tell process "Google Chrome"
|
||||
set frontmost to true
|
||||
keystroke "i" using {command down, option down}
|
||||
delay 1
|
||||
keystroke "p" using {command down, shift down}
|
||||
delay 0.2
|
||||
keystroke "Show Network"
|
||||
key code 36
|
||||
end tell
|
||||
end tell
|
||||
OSA
|
||||
ok "opened Chrome at $SERVER_URL/ and requested DevTools Network panel"
|
||||
}
|
||||
|
||||
cookie_header_from_jar() {
|
||||
local jar="$1"
|
||||
awk '
|
||||
BEGIN { first = 1 }
|
||||
/^$/ { next }
|
||||
/^#/ {
|
||||
if ($0 !~ /^#HttpOnly_/) next
|
||||
sub(/^#HttpOnly_/, "")
|
||||
}
|
||||
NF >= 7 {
|
||||
if (!first) printf "; "
|
||||
printf "%s=%s", $6, $7
|
||||
first = 0
|
||||
}
|
||||
END {
|
||||
if (!first) printf "\n"
|
||||
}
|
||||
' "$jar"
|
||||
}
|
||||
|
||||
# Build a Playwright storageState file from a raw Cookie header on stdin,
|
||||
# keeping only the better-auth cookies. See references/auth.md for why the
|
||||
# header must come from a Network request (HttpOnly) and why httpOnly=false.
|
||||
cmd_web() {
|
||||
mkdir -p "$AUTH_DIR"
|
||||
local raw
|
||||
raw="$(cat)"
|
||||
COOKIE_INPUT="$raw" python3 - "$STATE_FILE" << 'PY'
|
||||
import json, os, sys, time
|
||||
python3 - "$STATE_FILE" << 'PY'
|
||||
import json, sys, time
|
||||
|
||||
raw = os.environ.get("COOKIE_INPUT", "").strip()
|
||||
cookie_lines = []
|
||||
for line in raw.splitlines():
|
||||
stripped = line.strip()
|
||||
if not stripped:
|
||||
continue
|
||||
if stripped.lower().startswith("cookie:"):
|
||||
cookie_lines.append(stripped.split(":", 1)[1].strip())
|
||||
else:
|
||||
cookie_lines.append(stripped)
|
||||
raw = sys.stdin.read().strip()
|
||||
if raw.lower().startswith("cookie:"):
|
||||
raw = raw.split(":", 1)[1].strip()
|
||||
|
||||
raw = "; ".join(cookie_lines)
|
||||
|
||||
WANTED = {"better-auth.session_token", "better-auth.session_data", "better-auth.state"}
|
||||
WANTED = {"better-auth.session_token", "better-auth.state"}
|
||||
exp = int(time.time()) + 30 * 24 * 3600 # 30 days
|
||||
|
||||
cookies = []
|
||||
for pair in raw.split(";"):
|
||||
pair = pair.strip()
|
||||
for pair in raw.split("; "):
|
||||
if "=" not in pair:
|
||||
continue
|
||||
name, _, value = pair.partition("=")
|
||||
@@ -450,79 +146,14 @@ with open(sys.argv[1], "w") as f:
|
||||
json.dump({"cookies": cookies, "origins": []}, f, indent=2)
|
||||
print(f"wrote {len(cookies)} cookie(s) to {sys.argv[1]}")
|
||||
PY
|
||||
agent-browser --session "$SESSION" state load "$STATE_FILE"
|
||||
cmd_web_verify
|
||||
}
|
||||
|
||||
cmd_web_seed() {
|
||||
check_server || return 1
|
||||
mkdir -p "$AUTH_DIR"
|
||||
|
||||
local cookie_jar="$AUTH_DIR/web-seed-cookie.jar"
|
||||
local response_body="$AUTH_DIR/web-seed-response.json"
|
||||
local payload code
|
||||
payload="$(
|
||||
SEED_EMAIL="$SEED_EMAIL" SEED_PASSWORD="$SEED_PASSWORD" python3 - << 'PY'
|
||||
import json
|
||||
import os
|
||||
|
||||
print(json.dumps({
|
||||
"callbackURL": "/",
|
||||
"email": os.environ["SEED_EMAIL"],
|
||||
"password": os.environ["SEED_PASSWORD"],
|
||||
}))
|
||||
PY
|
||||
)"
|
||||
|
||||
code=$(curl -sS -o "$response_body" -w '%{http_code}' \
|
||||
-c "$cookie_jar" \
|
||||
-H 'Content-Type: application/json' \
|
||||
-X POST "$SERVER_URL/api/auth/sign-in/email" \
|
||||
--data "$payload" 2> /dev/null || true)
|
||||
|
||||
if [[ ! "$code" =~ ^[23] ]]; then
|
||||
bad "seed user sign-in failed at $SERVER_URL/api/auth/sign-in/email (http_code='$code')"
|
||||
note "make sure the seed user exists:"
|
||||
note "./.agents/skills/agent-testing/scripts/init-dev-env.sh seed-user"
|
||||
return 1
|
||||
fi
|
||||
|
||||
local cookie_header
|
||||
cookie_header="$(cookie_header_from_jar "$cookie_jar")"
|
||||
if [[ -z "$cookie_header" ]]; then
|
||||
bad "seed sign-in succeeded but no cookies were written to $cookie_jar"
|
||||
return 1
|
||||
fi
|
||||
|
||||
printf '%s\n' "$cookie_header" | cmd_web
|
||||
}
|
||||
|
||||
cmd_web_verify() {
|
||||
local skip_server_check="${1:-}"
|
||||
if [[ "$skip_server_check" != "--skip-server-check" ]]; then
|
||||
check_server || return 1
|
||||
fi
|
||||
if [[ ! -f "$STATE_FILE" ]]; then
|
||||
bad "no web auth state for agent-browser"
|
||||
note "for the seeded local user, run: $0 web-seed"
|
||||
note "or copy the Cookie header from Chrome DevTools (Network tab), then:"
|
||||
note "pbpaste | $0 web"
|
||||
return 1
|
||||
fi
|
||||
check_agent_browser || return 1
|
||||
if ! agent-browser --session "$SESSION" state load "$STATE_FILE" > /dev/null; then
|
||||
bad "failed to load web auth state into agent-browser session '$SESSION'"
|
||||
return 1
|
||||
fi
|
||||
if ! agent-browser --session "$SESSION" open "$SERVER_URL/" > /dev/null; then
|
||||
bad "failed to open $SERVER_URL in agent-browser session '$SESSION'"
|
||||
return 1
|
||||
fi
|
||||
agent-browser --session "$SESSION" open "$SERVER_URL/" > /dev/null
|
||||
local url
|
||||
url=$(agent-browser --session "$SESSION" get url 2> /dev/null || true)
|
||||
if [[ -z "$url" ]]; then
|
||||
bad "agent-browser session '$SESSION' did not report a current URL"
|
||||
return 1
|
||||
fi
|
||||
url=$(agent-browser --session "$SESSION" get url)
|
||||
if [[ "$url" == *"/signin"* || "$url" == *"/login"* ]]; then
|
||||
bad "agent-browser session '$SESSION' NOT authenticated (landed on $url)"
|
||||
note "re-copy the Cookie header and re-run: pbpaste | $0 web"
|
||||
@@ -532,22 +163,12 @@ cmd_web_verify() {
|
||||
}
|
||||
|
||||
case "${1:-status}" in
|
||||
status)
|
||||
shift || true
|
||||
cmd_status "$@"
|
||||
;;
|
||||
cli-seed) cmd_cli_seed ;;
|
||||
status) cmd_status ;;
|
||||
cli) cmd_cli ;;
|
||||
open-chrome)
|
||||
shift || true
|
||||
cmd_open_chrome "$@"
|
||||
;;
|
||||
web-seed) cmd_web_seed ;;
|
||||
web) cmd_web ;;
|
||||
web-verify) cmd_web_verify ;;
|
||||
-h|--help) usage ;;
|
||||
*)
|
||||
echo "Usage: $0 {status|cli-seed|cli|open-chrome|web-seed|web|web-verify}" >&2
|
||||
echo "Usage: $0 {status|cli|web|web-verify}" >&2
|
||||
exit 2
|
||||
;;
|
||||
esac
|
||||
|
||||
@@ -1,197 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# Smoke tests for setup-auth.sh. Uses a temporary agent-browser stub and local
|
||||
# HTTP server, so it does not need real browser auth.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
SCRIPT="$SCRIPT_DIR/setup-auth.sh"
|
||||
|
||||
fail() {
|
||||
echo "FAIL: $*" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
assert_contains() {
|
||||
local file="$1"
|
||||
local text="$2"
|
||||
grep -Fq "$text" "$file" || fail "expected '$text' in $file"
|
||||
}
|
||||
|
||||
tmp_dir="$(mktemp -d)"
|
||||
server_pid=""
|
||||
|
||||
cleanup() {
|
||||
if [[ -n "$server_pid" ]]; then
|
||||
kill "$server_pid" > /dev/null 2>&1 || true
|
||||
wait "$server_pid" > /dev/null 2>&1 || true
|
||||
fi
|
||||
rm -rf "$tmp_dir"
|
||||
}
|
||||
trap cleanup EXIT
|
||||
export HOME="$tmp_dir/home"
|
||||
|
||||
port="$(python3 - << 'PY'
|
||||
import socket
|
||||
|
||||
sock = socket.socket()
|
||||
sock.bind(("127.0.0.1", 0))
|
||||
print(sock.getsockname()[1])
|
||||
sock.close()
|
||||
PY
|
||||
)"
|
||||
|
||||
python3 - "$port" << 'PY' > "$tmp_dir/http.log" 2>&1 &
|
||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||
import sys
|
||||
|
||||
|
||||
class Handler(BaseHTTPRequestHandler):
|
||||
def do_GET(self):
|
||||
if self.path.startswith("/api/v1/users/me"):
|
||||
if self.headers.get("authorization") != "Bearer sk-lh-agenttesting0001":
|
||||
self.send_response(401)
|
||||
self.end_headers()
|
||||
self.wfile.write(b'{"success":false}')
|
||||
return
|
||||
|
||||
self.send_response(200)
|
||||
self.send_header("Content-Type", "application/json")
|
||||
self.end_headers()
|
||||
self.wfile.write(b'{"success":true,"data":{"id":"user_agent_testing_001"}}')
|
||||
return
|
||||
|
||||
self.send_response(200)
|
||||
self.end_headers()
|
||||
self.wfile.write(b"ok")
|
||||
|
||||
def do_POST(self):
|
||||
length = int(self.headers.get("content-length") or "0")
|
||||
if length:
|
||||
self.rfile.read(length)
|
||||
|
||||
if self.path != "/api/auth/sign-in/email":
|
||||
self.send_response(404)
|
||||
self.end_headers()
|
||||
return
|
||||
|
||||
self.send_response(200)
|
||||
self.send_header(
|
||||
"Set-Cookie",
|
||||
"better-auth.session_token=seed.token; Path=/; HttpOnly; SameSite=Lax",
|
||||
)
|
||||
self.send_header(
|
||||
"Set-Cookie",
|
||||
"better-auth.session_data=seed.data; Path=/; HttpOnly; SameSite=Lax",
|
||||
)
|
||||
self.send_header("Content-Type", "application/json")
|
||||
self.end_headers()
|
||||
self.wfile.write(b'{"ok":true}')
|
||||
|
||||
def log_message(self, format, *args):
|
||||
return
|
||||
|
||||
|
||||
ThreadingHTTPServer(("localhost", int(sys.argv[1])), Handler).serve_forever()
|
||||
PY
|
||||
server_pid="$!"
|
||||
|
||||
server_url="http://localhost:$port"
|
||||
for _ in {1..50}; do
|
||||
if curl -s -o /dev/null "$server_url/"; then
|
||||
break
|
||||
fi
|
||||
sleep 0.1
|
||||
done
|
||||
curl -s -o /dev/null "$server_url/" || fail "test HTTP server did not start"
|
||||
|
||||
mkdir -p "$tmp_dir/bin" "$tmp_dir/auth"
|
||||
cat > "$tmp_dir/bin/agent-browser" << 'SH'
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
if [[ "${1:-}" == "--session" ]]; then
|
||||
shift 2
|
||||
fi
|
||||
|
||||
case "${1:-}" in
|
||||
state)
|
||||
[[ "${2:-}" == "load" ]] || exit 2
|
||||
[[ -f "${3:-}" ]] || exit 1
|
||||
;;
|
||||
open)
|
||||
printf '%s\n' "${2:-}" > "${AGENT_BROWSER_URL_FILE:?}"
|
||||
;;
|
||||
get)
|
||||
[[ "${2:-}" == "url" ]] || exit 2
|
||||
cat "${AGENT_BROWSER_URL_FILE:?}"
|
||||
;;
|
||||
*)
|
||||
echo "unexpected agent-browser command: $*" >&2
|
||||
exit 2
|
||||
;;
|
||||
esac
|
||||
SH
|
||||
chmod +x "$tmp_dir/bin/agent-browser"
|
||||
|
||||
export PATH="$tmp_dir/bin:$PATH"
|
||||
export AUTH_DIR="$tmp_dir/auth"
|
||||
export SESSION="setup-auth-test"
|
||||
export SERVER_URL="$server_url"
|
||||
export AGENT_BROWSER_URL_FILE="$tmp_dir/current-url"
|
||||
|
||||
cookie_header="Cookie: foo=bar; better-auth.session_token=test.token; better-auth.session_data=encoded%3D; theme=dark"
|
||||
printf '%s\n' "$cookie_header" | "$SCRIPT" web > "$tmp_dir/web.out"
|
||||
|
||||
python3 - "$AUTH_DIR/web-state.json" << 'PY'
|
||||
import json, sys
|
||||
|
||||
with open(sys.argv[1]) as f:
|
||||
state = json.load(f)
|
||||
|
||||
names = {cookie["name"] for cookie in state["cookies"]}
|
||||
expected = {"better-auth.session_token", "better-auth.session_data"}
|
||||
if names != expected:
|
||||
raise SystemExit(f"unexpected cookies: {sorted(names)}")
|
||||
PY
|
||||
|
||||
"$SCRIPT" web-seed > "$tmp_dir/web-seed.out"
|
||||
|
||||
python3 - "$AUTH_DIR/web-state.json" << 'PY'
|
||||
import json, sys
|
||||
|
||||
with open(sys.argv[1]) as f:
|
||||
state = json.load(f)
|
||||
|
||||
values = {cookie["name"]: cookie["value"] for cookie in state["cookies"]}
|
||||
expected = {
|
||||
"better-auth.session_token": "seed.token",
|
||||
"better-auth.session_data": "seed.data",
|
||||
}
|
||||
if values != expected:
|
||||
raise SystemExit(f"unexpected seeded cookies: {values}")
|
||||
PY
|
||||
|
||||
"$SCRIPT" status --surface web > "$tmp_dir/status.out"
|
||||
assert_contains "$tmp_dir/status.out" "surface=web"
|
||||
assert_contains "$tmp_dir/status.out" "web auth green"
|
||||
|
||||
"$SCRIPT" cli-seed > "$tmp_dir/cli-seed.out"
|
||||
assert_contains "$tmp_dir/cli-seed.out" "CLI API-key auth valid"
|
||||
assert_contains "$tmp_dir/cli-seed.out" "settings saved at: $HOME/.lobehub-dev/settings.json"
|
||||
|
||||
if "$SCRIPT" status --surface cli > "$tmp_dir/cli-no-env.out"; then
|
||||
fail "cli status without API key unexpectedly passed"
|
||||
fi
|
||||
assert_contains "$tmp_dir/cli-no-env.out" "CLI not logged in"
|
||||
|
||||
LOBEHUB_CLI_API_KEY=sk-lh-agenttesting0001 "$SCRIPT" status --surface cli > "$tmp_dir/cli-status.out"
|
||||
assert_contains "$tmp_dir/cli-status.out" "CLI API-key auth valid"
|
||||
assert_contains "$tmp_dir/cli-status.out" "cli auth green"
|
||||
|
||||
if printf 'foo=bar\n' | "$SCRIPT" web > "$tmp_dir/invalid.out" 2> "$tmp_dir/invalid.err"; then
|
||||
fail "invalid cookie unexpectedly passed"
|
||||
fi
|
||||
assert_contains "$tmp_dir/invalid.err" "no better-auth cookies found"
|
||||
|
||||
echo "setup-auth tests passed"
|
||||
@@ -1,377 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# Print the resolved local test environment for agent-testing.
|
||||
#
|
||||
# This is intentionally read-only. It mirrors scripts/runWithEnv.mts precedence:
|
||||
# .env -> .env.$NODE_ENV -> .env.local -> .env.$NODE_ENV.local, then shell env.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
REPO_ROOT="$(cd "$SCRIPT_DIR/../../../.." && pwd)"
|
||||
NODE_ENV="${NODE_ENV:-development}"
|
||||
|
||||
VALUE_APP_URL=""
|
||||
VALUE_PORT=""
|
||||
VALUE_SERVER_URL=""
|
||||
VALUE_AUTH_TRUSTED_ORIGINS=""
|
||||
VALUE_SPA_PORT=""
|
||||
VALUE_MOBILE_SPA_PORT=""
|
||||
VALUE_DESKTOP_PORT=""
|
||||
|
||||
SOURCE_APP_URL=""
|
||||
SOURCE_PORT=""
|
||||
SOURCE_SERVER_URL=""
|
||||
SOURCE_AUTH_TRUSTED_ORIGINS=""
|
||||
SOURCE_SPA_PORT=""
|
||||
SOURCE_MOBILE_SPA_PORT=""
|
||||
SOURCE_DESKTOP_PORT=""
|
||||
|
||||
LOADED_ENV_FILES=""
|
||||
|
||||
keys() {
|
||||
printf '%s\n' \
|
||||
APP_URL \
|
||||
PORT \
|
||||
SERVER_URL \
|
||||
AUTH_TRUSTED_ORIGINS \
|
||||
SPA_PORT \
|
||||
MOBILE_SPA_PORT \
|
||||
DESKTOP_PORT
|
||||
}
|
||||
|
||||
trim() {
|
||||
local value="$1"
|
||||
value="${value#"${value%%[![:space:]]*}"}"
|
||||
value="${value%"${value##*[![:space:]]}"}"
|
||||
printf '%s' "$value"
|
||||
}
|
||||
|
||||
workspace_root() {
|
||||
local root="$REPO_ROOT"
|
||||
local name
|
||||
name="$(basename "$root")"
|
||||
|
||||
if [[ "$name" == "lobehub" ]]; then
|
||||
local parent parent_name
|
||||
parent="$(cd "$root/.." && pwd)"
|
||||
parent_name="$(basename "$parent")"
|
||||
if [[ "$parent_name" == lobehub-cloud* ]]; then
|
||||
root="$parent"
|
||||
fi
|
||||
fi
|
||||
|
||||
printf '%s\n' "$root"
|
||||
}
|
||||
|
||||
workspace_offset() {
|
||||
local name="$1"
|
||||
|
||||
case "$name" in
|
||||
lobehub-cloud)
|
||||
printf '0\n'
|
||||
;;
|
||||
lobehub-cloud-*)
|
||||
local suffix="${name#lobehub-cloud-}"
|
||||
if [[ "$suffix" =~ ^[0-9]+$ ]]; then
|
||||
printf '%s\n' "$((10#$suffix))"
|
||||
else
|
||||
printf '\n'
|
||||
fi
|
||||
;;
|
||||
*)
|
||||
printf '\n'
|
||||
;;
|
||||
esac
|
||||
}
|
||||
|
||||
default_port() {
|
||||
local base="$1"
|
||||
local fallback="$2"
|
||||
local root name offset
|
||||
root="$(workspace_root)"
|
||||
name="$(basename "$root")"
|
||||
offset="$(workspace_offset "$name")"
|
||||
|
||||
if [[ -n "$offset" ]]; then
|
||||
printf '%s\n' "$((base + offset))"
|
||||
else
|
||||
printf '%s\n' "$fallback"
|
||||
fi
|
||||
}
|
||||
|
||||
url_port() {
|
||||
local url="$1"
|
||||
local hostport
|
||||
hostport="${url#*://}"
|
||||
hostport="${hostport%%/*}"
|
||||
|
||||
if [[ "$hostport" == *:* ]]; then
|
||||
local port="${hostport##*:}"
|
||||
if [[ "$port" =~ ^[0-9]+$ ]]; then
|
||||
printf '%s\n' "$port"
|
||||
return 0
|
||||
fi
|
||||
fi
|
||||
|
||||
return 1
|
||||
}
|
||||
|
||||
url_origin() {
|
||||
local url="$1"
|
||||
local scheme rest hostport
|
||||
if [[ "$url" == *"://"* ]]; then
|
||||
scheme="${url%%://*}"
|
||||
rest="${url#*://}"
|
||||
hostport="${rest%%/*}"
|
||||
printf '%s://%s\n' "$scheme" "$hostport"
|
||||
else
|
||||
printf '%s\n' "$url"
|
||||
fi
|
||||
}
|
||||
|
||||
set_value() {
|
||||
local key="$1"
|
||||
local value="$2"
|
||||
local source="$3"
|
||||
|
||||
case "$key" in
|
||||
APP_URL) VALUE_APP_URL="$value"; SOURCE_APP_URL="$source" ;;
|
||||
PORT) VALUE_PORT="$value"; SOURCE_PORT="$source" ;;
|
||||
SERVER_URL) VALUE_SERVER_URL="$value"; SOURCE_SERVER_URL="$source" ;;
|
||||
AUTH_TRUSTED_ORIGINS) VALUE_AUTH_TRUSTED_ORIGINS="$value"; SOURCE_AUTH_TRUSTED_ORIGINS="$source" ;;
|
||||
SPA_PORT) VALUE_SPA_PORT="$value"; SOURCE_SPA_PORT="$source" ;;
|
||||
MOBILE_SPA_PORT) VALUE_MOBILE_SPA_PORT="$value"; SOURCE_MOBILE_SPA_PORT="$source" ;;
|
||||
DESKTOP_PORT) VALUE_DESKTOP_PORT="$value"; SOURCE_DESKTOP_PORT="$source" ;;
|
||||
esac
|
||||
}
|
||||
|
||||
value_for() {
|
||||
case "$1" in
|
||||
APP_URL) printf '%s\n' "$VALUE_APP_URL" ;;
|
||||
PORT) printf '%s\n' "$VALUE_PORT" ;;
|
||||
SERVER_URL) printf '%s\n' "$VALUE_SERVER_URL" ;;
|
||||
AUTH_TRUSTED_ORIGINS) printf '%s\n' "$VALUE_AUTH_TRUSTED_ORIGINS" ;;
|
||||
SPA_PORT) printf '%s\n' "$VALUE_SPA_PORT" ;;
|
||||
MOBILE_SPA_PORT) printf '%s\n' "$VALUE_MOBILE_SPA_PORT" ;;
|
||||
DESKTOP_PORT) printf '%s\n' "$VALUE_DESKTOP_PORT" ;;
|
||||
esac
|
||||
}
|
||||
|
||||
source_for() {
|
||||
case "$1" in
|
||||
APP_URL) printf '%s\n' "$SOURCE_APP_URL" ;;
|
||||
PORT) printf '%s\n' "$SOURCE_PORT" ;;
|
||||
SERVER_URL) printf '%s\n' "$SOURCE_SERVER_URL" ;;
|
||||
AUTH_TRUSTED_ORIGINS) printf '%s\n' "$SOURCE_AUTH_TRUSTED_ORIGINS" ;;
|
||||
SPA_PORT) printf '%s\n' "$SOURCE_SPA_PORT" ;;
|
||||
MOBILE_SPA_PORT) printf '%s\n' "$SOURCE_MOBILE_SPA_PORT" ;;
|
||||
DESKTOP_PORT) printf '%s\n' "$SOURCE_DESKTOP_PORT" ;;
|
||||
esac
|
||||
}
|
||||
|
||||
is_tracked_key() {
|
||||
case "$1" in
|
||||
APP_URL|PORT|SERVER_URL|AUTH_TRUSTED_ORIGINS|SPA_PORT|MOBILE_SPA_PORT|DESKTOP_PORT) return 0 ;;
|
||||
*) return 1 ;;
|
||||
esac
|
||||
}
|
||||
|
||||
parse_env_file() {
|
||||
local file="$1"
|
||||
local root="$2"
|
||||
local label="${file#$root/}"
|
||||
local line key value
|
||||
|
||||
[[ -f "$file" ]] || return 0
|
||||
if [[ -z "$LOADED_ENV_FILES" ]]; then
|
||||
LOADED_ENV_FILES="$label"
|
||||
else
|
||||
LOADED_ENV_FILES="$LOADED_ENV_FILES, $label"
|
||||
fi
|
||||
|
||||
while IFS= read -r line || [[ -n "$line" ]]; do
|
||||
line="$(trim "$line")"
|
||||
[[ -z "$line" || "$line" == \#* ]] && continue
|
||||
|
||||
if [[ "$line" == export[[:space:]]* ]]; then
|
||||
line="$(trim "${line#export}")"
|
||||
fi
|
||||
|
||||
[[ "$line" == *=* ]] || continue
|
||||
key="$(trim "${line%%=*}")"
|
||||
value="$(trim "${line#*=}")"
|
||||
is_tracked_key "$key" || continue
|
||||
|
||||
if [[ "$value" == \"*\" && "$value" == *\" && ${#value} -ge 2 ]]; then
|
||||
value="${value:1:${#value}-2}"
|
||||
elif [[ "$value" == \'* && "$value" == *\' && ${#value} -ge 2 ]]; then
|
||||
value="${value:1:${#value}-2}"
|
||||
fi
|
||||
|
||||
set_value "$key" "$value" "$label"
|
||||
done < "$file"
|
||||
}
|
||||
|
||||
apply_env_files() {
|
||||
local root="$1"
|
||||
parse_env_file "$root/.env" "$root"
|
||||
parse_env_file "$root/.env.$NODE_ENV" "$root"
|
||||
parse_env_file "$root/.env.local" "$root"
|
||||
parse_env_file "$root/.env.$NODE_ENV.local" "$root"
|
||||
}
|
||||
|
||||
apply_shell_overrides() {
|
||||
local key value
|
||||
while IFS= read -r key; do
|
||||
if [[ -n "${!key+x}" ]]; then
|
||||
value="${!key}"
|
||||
set_value "$key" "$value" "shell"
|
||||
fi
|
||||
done < <(keys)
|
||||
}
|
||||
|
||||
resolve_defaults() {
|
||||
local app_port spa_port mobile_spa_port desktop_port
|
||||
app_port="$(default_port 3020 3010)"
|
||||
spa_port="$(default_port 9800 9876)"
|
||||
mobile_spa_port="$(default_port 3810 3012)"
|
||||
desktop_port="$(default_port 3030 3015)"
|
||||
|
||||
if [[ -z "$VALUE_APP_URL" ]]; then
|
||||
set_value APP_URL "http://localhost:$app_port" "inferred"
|
||||
fi
|
||||
|
||||
if [[ -z "$VALUE_PORT" ]]; then
|
||||
if app_port="$(url_port "$VALUE_APP_URL")"; then
|
||||
set_value PORT "$app_port" "inferred from APP_URL"
|
||||
else
|
||||
set_value PORT "$(default_port 3020 3010)" "inferred"
|
||||
fi
|
||||
fi
|
||||
|
||||
if [[ -z "$VALUE_SERVER_URL" ]]; then
|
||||
set_value SERVER_URL "$VALUE_APP_URL" "from APP_URL"
|
||||
fi
|
||||
|
||||
if [[ -z "$VALUE_SPA_PORT" ]]; then
|
||||
set_value SPA_PORT "$spa_port" "inferred"
|
||||
fi
|
||||
|
||||
if [[ -z "$VALUE_MOBILE_SPA_PORT" ]]; then
|
||||
set_value MOBILE_SPA_PORT "$mobile_spa_port" "inferred"
|
||||
fi
|
||||
|
||||
if [[ -z "$VALUE_DESKTOP_PORT" ]]; then
|
||||
set_value DESKTOP_PORT "$desktop_port" "inferred"
|
||||
fi
|
||||
|
||||
if [[ -z "$VALUE_AUTH_TRUSTED_ORIGINS" ]]; then
|
||||
set_value AUTH_TRUSTED_ORIGINS "$(url_origin "$VALUE_APP_URL"),http://localhost:$VALUE_SPA_PORT" "inferred"
|
||||
fi
|
||||
}
|
||||
|
||||
contains_origin() {
|
||||
local list="$1"
|
||||
local expected="$2"
|
||||
local item
|
||||
IFS=',' read -r -a items <<< "$list"
|
||||
for item in "${items[@]}"; do
|
||||
item="$(trim "$item")"
|
||||
[[ "$item" == "$expected" ]] && return 0
|
||||
done
|
||||
return 1
|
||||
}
|
||||
|
||||
print_exports() {
|
||||
local key value
|
||||
while IFS= read -r key; do
|
||||
value="$(value_for "$key")"
|
||||
printf 'export %s=%q\n' "$key" "$value"
|
||||
done < <(keys)
|
||||
}
|
||||
|
||||
print_value() {
|
||||
local key="$1"
|
||||
if ! is_tracked_key "$key"; then
|
||||
echo "unknown key: $key" >&2
|
||||
exit 2
|
||||
fi
|
||||
value_for "$key"
|
||||
}
|
||||
|
||||
print_human() {
|
||||
local root="$1"
|
||||
local key value source
|
||||
|
||||
echo "agent-testing test env:"
|
||||
printf ' workspace: %s\n' "$root"
|
||||
printf ' NODE_ENV: %s\n' "$NODE_ENV"
|
||||
printf ' env files: %s\n' "${LOADED_ENV_FILES:-none}"
|
||||
echo
|
||||
echo "resolved values:"
|
||||
while IFS= read -r key; do
|
||||
value="$(value_for "$key")"
|
||||
source="$(source_for "$key")"
|
||||
printf ' %-22s %s (%s)\n' "$key=$value" "" "$source"
|
||||
done < <(keys)
|
||||
echo
|
||||
echo "checks:"
|
||||
|
||||
local app_origin spa_origin app_port
|
||||
app_origin="$(url_origin "$VALUE_APP_URL")"
|
||||
spa_origin="http://localhost:$VALUE_SPA_PORT"
|
||||
if app_port="$(url_port "$VALUE_APP_URL")" && [[ "$app_port" == "$VALUE_PORT" ]]; then
|
||||
printf ' OK PORT matches APP_URL (%s)\n' "$VALUE_PORT"
|
||||
else
|
||||
printf ' WARN PORT (%s) does not match APP_URL (%s)\n' "$VALUE_PORT" "$VALUE_APP_URL"
|
||||
fi
|
||||
|
||||
if contains_origin "$VALUE_AUTH_TRUSTED_ORIGINS" "$app_origin"; then
|
||||
printf ' OK AUTH_TRUSTED_ORIGINS includes %s\n' "$app_origin"
|
||||
else
|
||||
printf ' WARN AUTH_TRUSTED_ORIGINS is missing %s\n' "$app_origin"
|
||||
fi
|
||||
|
||||
if contains_origin "$VALUE_AUTH_TRUSTED_ORIGINS" "$spa_origin"; then
|
||||
printf ' OK AUTH_TRUSTED_ORIGINS includes %s\n' "$spa_origin"
|
||||
else
|
||||
printf ' WARN AUTH_TRUSTED_ORIGINS is missing %s\n' "$spa_origin"
|
||||
fi
|
||||
}
|
||||
|
||||
usage() {
|
||||
cat << EOF
|
||||
Usage:
|
||||
$0 # print resolved test environment
|
||||
$0 --exports # print source-able export lines
|
||||
$0 --value KEY # print one resolved value
|
||||
|
||||
Tracked keys:
|
||||
APP_URL PORT SERVER_URL AUTH_TRUSTED_ORIGINS SPA_PORT MOBILE_SPA_PORT DESKTOP_PORT
|
||||
EOF
|
||||
}
|
||||
|
||||
ROOT="$(workspace_root)"
|
||||
apply_env_files "$ROOT"
|
||||
apply_shell_overrides
|
||||
resolve_defaults
|
||||
|
||||
case "${1:-}" in
|
||||
"")
|
||||
print_human "$ROOT"
|
||||
;;
|
||||
--exports)
|
||||
print_exports
|
||||
;;
|
||||
--value)
|
||||
print_value "${2:-}"
|
||||
;;
|
||||
-h|--help)
|
||||
usage
|
||||
;;
|
||||
*)
|
||||
echo "unknown option: $1" >&2
|
||||
usage >&2
|
||||
exit 2
|
||||
;;
|
||||
esac
|
||||
@@ -1,57 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# Smoke tests for test-env.sh.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
|
||||
fail() {
|
||||
echo "FAIL: $*" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
assert_eq() {
|
||||
local actual="$1"
|
||||
local expected="$2"
|
||||
[[ "$actual" == "$expected" ]] || fail "expected '$expected', got '$actual'"
|
||||
}
|
||||
|
||||
assert_contains() {
|
||||
local file="$1"
|
||||
local text="$2"
|
||||
grep -Fq "$text" "$file" || fail "expected '$text' in $file"
|
||||
}
|
||||
|
||||
tmp_dir="$(mktemp -d)"
|
||||
trap 'rm -rf "$tmp_dir"' EXIT
|
||||
|
||||
mkdir -p "$tmp_dir/lobehub-cloud-1/.agents/skills" "$tmp_dir/lobehub/.agents/skills"
|
||||
ln -s "$SCRIPT_DIR/.." "$tmp_dir/lobehub-cloud-1/.agents/skills/agent-testing"
|
||||
ln -s "$SCRIPT_DIR/.." "$tmp_dir/lobehub/.agents/skills/agent-testing"
|
||||
|
||||
cloud_script="$tmp_dir/lobehub-cloud-1/.agents/skills/agent-testing/scripts/test-env.sh"
|
||||
oss_script="$tmp_dir/lobehub/.agents/skills/agent-testing/scripts/test-env.sh"
|
||||
|
||||
assert_eq "$("$cloud_script" --value SERVER_URL)" "http://localhost:3021"
|
||||
assert_eq "$("$cloud_script" --value SPA_PORT)" "9801"
|
||||
assert_eq "$("$cloud_script" --value MOBILE_SPA_PORT)" "3811"
|
||||
assert_eq "$("$cloud_script" --value DESKTOP_PORT)" "3031"
|
||||
assert_eq "$("$oss_script" --value SERVER_URL)" "http://localhost:3010"
|
||||
|
||||
cat > "$tmp_dir/lobehub-cloud-1/.env" << 'EOF'
|
||||
APP_URL=http://localhost:4123
|
||||
PORT=4123
|
||||
AUTH_TRUSTED_ORIGINS=http://localhost:4123,http://localhost:9823
|
||||
SPA_PORT=9823
|
||||
MOBILE_SPA_PORT=3823
|
||||
DESKTOP_PORT=3043
|
||||
EOF
|
||||
|
||||
assert_eq "$("$cloud_script" --value SERVER_URL)" "http://localhost:4123"
|
||||
assert_eq "$("$cloud_script" --value SPA_PORT)" "9823"
|
||||
"$cloud_script" --exports > "$tmp_dir/exports.out"
|
||||
assert_contains "$tmp_dir/exports.out" "export APP_URL=http://localhost:4123"
|
||||
assert_contains "$tmp_dir/exports.out" "export SERVER_URL=http://localhost:4123"
|
||||
assert_contains "$tmp_dir/exports.out" "export AUTH_TRUSTED_ORIGINS=http://localhost:4123\\,http://localhost:9823"
|
||||
|
||||
echo "test-env tests passed"
|
||||
@@ -10,32 +10,23 @@ backend-only changes prefer [../cli/index.md](../cli/index.md).
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Complete [Step 0.0](../SKILL.md#00-resolve-the-current-test-environment) (resolve ports) and [Step -1](../SKILL.md#step--1--plan-approval-for-non-trivial-tests) (plan approval) first.
|
||||
- Local dev server running — [../references/dev-server.md](../references/dev-server.md)
|
||||
- Web auth verified in agent-browser — prefer `setup-auth.sh web-seed`, see [auth decision flow](../references/auth.md#web--decision-flow).
|
||||
|
||||
## Option A — agent-browser with seeded auth (recommended)
|
||||
- Web auth injected into agent-browser — [../references/auth.md](../references/auth.md):
|
||||
|
||||
```bash
|
||||
./.agents/skills/agent-testing/scripts/init-dev-env.sh seed-user
|
||||
./.agents/skills/agent-testing/scripts/setup-auth.sh web-seed
|
||||
pbpaste | ./.agents/skills/agent-testing/scripts/setup-auth.sh web # after copying the Cookie header
|
||||
```
|
||||
|
||||
Then drive the verified session:
|
||||
## Option A — agent-browser with injected auth (recommended)
|
||||
|
||||
```bash
|
||||
SESSION=lobehub-dev
|
||||
|
||||
agent-browser --session $SESSION open "$SERVER_URL/"
|
||||
agent-browser --session $SESSION open "http://localhost:3010/"
|
||||
agent-browser --session $SESSION snapshot -i
|
||||
# interact via refs — full command reference: ../references/agent-browser.md
|
||||
```
|
||||
|
||||
Use this session as the evidence source. Do not use ordinary Chrome screenshots
|
||||
or Chrome Network records as proof for Web tests; ordinary Chrome is only a
|
||||
fallback source for copying cookies into agent-browser when the seeded login is
|
||||
not available.
|
||||
|
||||
### Watch the API while driving the UI
|
||||
|
||||
```bash
|
||||
|
||||
@@ -49,4 +49,4 @@ Migration owner: @{pr-author}
|
||||
|
||||
The migration owner is responsible for rollout follow-up and incident handling for this schema change.
|
||||
|
||||
> \[!NOTE]: Replace `{pr-author}` with the actual PR author. Retrieve via `gh pr view <number> --json author --jq '.author.login'` or from commit metadata. Do not hardcode a username.
|
||||
> **Note for Claude**: Replace `{pr-author}` with the actual PR author. Retrieve via `gh pr view <number> --json author --jq '.author.login'` or from commit metadata. Do not hardcode a username.
|
||||
|
||||
@@ -18,4 +18,4 @@
|
||||
|
||||
@{pr-author}
|
||||
|
||||
> \[!NOTE]: Replace `{pr-author}` with the actual PR author. Retrieve via `gh pr view <number> --json author --jq '.author.login'`. Do not hardcode a username.
|
||||
> **Note for Claude**: Replace `{pr-author}` with the actual PR author. Retrieve via `gh pr view <number> --json author --jq '.author.login'`. Do not hardcode a username.
|
||||
|
||||
@@ -86,7 +86,7 @@ New AI model or provider support, typically contributed via community PRs.
|
||||
- These PR title prefixes (`feat` / `style`) are in the auto-tag trigger list
|
||||
- No special branch naming or manual release steps required — merging the PR triggers auto patch +1
|
||||
|
||||
### When an agent is involved
|
||||
### When Claude is involved
|
||||
|
||||
If asked to add model support, just create a normal feature PR. The title prefix will trigger the release automatically.
|
||||
|
||||
|
||||
@@ -29,7 +29,6 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@lobechat/agent-gateway-client": "workspace:*",
|
||||
"@lobechat/device-control": "workspace:*",
|
||||
"@lobechat/device-gateway-client": "workspace:*",
|
||||
"@lobechat/device-identity": "workspace:*",
|
||||
"@lobechat/heterogeneous-agents": "workspace:*",
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
packages:
|
||||
- '../../packages/agent-gateway-client'
|
||||
- '../../packages/device-control'
|
||||
- '../../packages/device-gateway-client'
|
||||
- '../../packages/device-identity'
|
||||
- '../../packages/heterogeneous-agents'
|
||||
|
||||
@@ -2,16 +2,9 @@ import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
|
||||
import {
|
||||
defaultGetLocalFilePreview,
|
||||
defaultGetProjectFileIndex,
|
||||
type DeviceControlDeps,
|
||||
executeDeviceRpc,
|
||||
} from '@lobechat/device-control';
|
||||
import type {
|
||||
AgentRunRequestMessage,
|
||||
DeviceSystemInfo,
|
||||
RpcRequestMessage,
|
||||
SystemInfoRequestMessage,
|
||||
ToolCallRequestMessage,
|
||||
} from '@lobechat/device-gateway-client';
|
||||
@@ -299,31 +292,6 @@ async function runConnect(options: ConnectOptions, isDaemonChild: boolean) {
|
||||
});
|
||||
});
|
||||
|
||||
// Handle generic server-internal device RPCs (git / workspace / file ops).
|
||||
// Shares the `@lobechat/device-control` dispatcher with the desktop app so the
|
||||
// CLI exposes the same remote-device control surface. File preview / index use
|
||||
// the package's portable defaults (no preview-protocol approval on the CLI).
|
||||
const deviceControlDeps: DeviceControlDeps = {
|
||||
getLocalFilePreview: defaultGetLocalFilePreview,
|
||||
getProjectFileIndex: defaultGetProjectFileIndex,
|
||||
};
|
||||
|
||||
client.on('rpc_request', async (request: RpcRequestMessage) => {
|
||||
const { method, params, requestId } = request;
|
||||
if (isDaemonChild) appendLog(`[RPC] ${method} (${requestId})`);
|
||||
else info(`Received rpc_request: method=${method} (${requestId})`);
|
||||
|
||||
try {
|
||||
const data = await executeDeviceRpc(method, params, deviceControlDeps);
|
||||
client.sendRpcResponse({ requestId, result: { data, success: true } });
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
if (isDaemonChild) appendLog(`[RPC ERROR] ${method}: ${message} (${requestId})`);
|
||||
else error(`rpc_request method=${method} failed: ${message}`);
|
||||
client.sendRpcResponse({ requestId, result: { error: message, success: false } });
|
||||
}
|
||||
});
|
||||
|
||||
// Handle gateway-dispatched agent runs (heterogeneous agents, e.g. Claude
|
||||
// Code). Mirrors the desktop app: spawn `lh hetero exec`, which owns the full
|
||||
// execution + server-ingest pipeline. Ack with the spawn outcome — `accepted`
|
||||
|
||||
@@ -4,7 +4,7 @@ import path from 'node:path';
|
||||
|
||||
export interface TaskEntry {
|
||||
agentId?: string;
|
||||
agentType: 'hermes' | 'openclaw';
|
||||
agentType: string;
|
||||
operationId: string;
|
||||
pid: number;
|
||||
startedAt: string;
|
||||
|
||||
@@ -4,14 +4,24 @@ import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { spawnHeteroAgentRun } from './agentRun';
|
||||
|
||||
const { spawnMock } = vi.hoisted(() => ({ spawnMock: vi.fn() }));
|
||||
const { removeTaskMock, saveTaskMock, spawnMock } = vi.hoisted(() => ({
|
||||
removeTaskMock: vi.fn(),
|
||||
saveTaskMock: vi.fn(),
|
||||
spawnMock: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('node:child_process', () => ({ spawn: spawnMock }));
|
||||
vi.mock('../daemon/taskRegistry', () => ({
|
||||
removeTask: removeTaskMock,
|
||||
saveTask: saveTaskMock,
|
||||
}));
|
||||
|
||||
const makeFakeChild = () => {
|
||||
const makeFakeChild = (pid = 1234) => {
|
||||
const child = new EventEmitter() as EventEmitter & {
|
||||
pid: number;
|
||||
stdin: { end: ReturnType<typeof vi.fn>; write: ReturnType<typeof vi.fn> };
|
||||
};
|
||||
child.pid = pid;
|
||||
child.stdin = { end: vi.fn(), write: vi.fn() };
|
||||
return child;
|
||||
};
|
||||
@@ -27,6 +37,8 @@ const baseParams = {
|
||||
|
||||
describe('spawnHeteroAgentRun', () => {
|
||||
afterEach(() => {
|
||||
removeTaskMock.mockReset();
|
||||
saveTaskMock.mockReset();
|
||||
spawnMock.mockReset();
|
||||
});
|
||||
|
||||
@@ -66,6 +78,7 @@ describe('spawnHeteroAgentRun', () => {
|
||||
]);
|
||||
expect(opts).toMatchObject({
|
||||
cwd: '/work/dir',
|
||||
detached: process.platform !== 'win32',
|
||||
env: expect.objectContaining({
|
||||
LOBEHUB_JWT: 'jwt-token',
|
||||
LOBEHUB_SERVER: 'https://app.lobehub.com',
|
||||
@@ -79,6 +92,15 @@ describe('spawnHeteroAgentRun', () => {
|
||||
await expect(ackPromise).resolves.toEqual({ status: 'accepted' });
|
||||
expect(child.stdin.write).toHaveBeenCalledWith(JSON.stringify('hi'));
|
||||
expect(child.stdin.end).toHaveBeenCalledTimes(1);
|
||||
expect(saveTaskMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
agentType: 'claudeCode',
|
||||
operationId: 'op-1',
|
||||
pid: 1234,
|
||||
taskId: 'op-1',
|
||||
topicId: 'tpc-1',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects (no stuck run) when the child errors before spawning, e.g. bad cwd', async () => {
|
||||
@@ -90,6 +112,24 @@ describe('spawnHeteroAgentRun', () => {
|
||||
|
||||
await expect(ackPromise).resolves.toEqual({ reason: 'spawn ENOENT', status: 'rejected' });
|
||||
expect(child.stdin.write).not.toHaveBeenCalled();
|
||||
expect(removeTaskMock).toHaveBeenCalledWith('op');
|
||||
});
|
||||
|
||||
it('removes the registered task when the child exits', async () => {
|
||||
const child = makeFakeChild(4321);
|
||||
spawnMock.mockReturnValue(child);
|
||||
|
||||
const ackPromise = spawnHeteroAgentRun({
|
||||
...baseParams,
|
||||
operationId: 'op-exit',
|
||||
topicId: 'tpc-exit',
|
||||
});
|
||||
child.emit('spawn');
|
||||
await ackPromise;
|
||||
|
||||
child.emit('exit', 0, null);
|
||||
|
||||
expect(removeTaskMock).toHaveBeenCalledWith('op-exit');
|
||||
});
|
||||
|
||||
it('appends --resume when resuming a session', () => {
|
||||
|
||||
@@ -5,6 +5,8 @@ import {
|
||||
type HeteroExecImageRef,
|
||||
} from '@lobechat/heterogeneous-agents/protocol';
|
||||
|
||||
import { removeTask, saveTask } from '../daemon/taskRegistry';
|
||||
|
||||
export interface SpawnHeteroAgentRunParams {
|
||||
agentType: string;
|
||||
cwd?: string;
|
||||
@@ -101,6 +103,7 @@ export function spawnHeteroAgentRun(
|
||||
|
||||
const child = spawn(process.execPath, [...process.execArgv, ...cliArgs], {
|
||||
cwd: workDir,
|
||||
detached: process.platform !== 'win32',
|
||||
env: {
|
||||
...process.env,
|
||||
LOBEHUB_JWT: jwt,
|
||||
@@ -109,7 +112,27 @@ export function spawnHeteroAgentRun(
|
||||
stdio: ['pipe', 'inherit', 'inherit'],
|
||||
});
|
||||
|
||||
let taskSaved = false;
|
||||
const saveRunningTask = () => {
|
||||
if (taskSaved || child.pid === undefined) return;
|
||||
taskSaved = true;
|
||||
saveTask({
|
||||
agentType,
|
||||
operationId,
|
||||
pid: child.pid,
|
||||
startedAt: new Date().toISOString(),
|
||||
taskId: operationId,
|
||||
topicId,
|
||||
});
|
||||
};
|
||||
|
||||
saveRunningTask();
|
||||
|
||||
child.once('spawn', () => {
|
||||
if (child.pid !== undefined) {
|
||||
saveRunningTask();
|
||||
}
|
||||
|
||||
// Only safe to write stdin once the process actually started.
|
||||
try {
|
||||
child.stdin?.write(stdinPayload);
|
||||
@@ -123,11 +146,13 @@ export function spawnHeteroAgentRun(
|
||||
});
|
||||
|
||||
child.once('error', (err) => {
|
||||
removeTask(operationId);
|
||||
logger?.error?.(`hetero exec spawn failed (op=${operationId}): ${err.message}`);
|
||||
settle({ reason: err.message, status: 'rejected' });
|
||||
});
|
||||
|
||||
child.on('exit', (code, signal) => {
|
||||
removeTask(operationId);
|
||||
logger?.info?.(`hetero exec exited (op=${operationId}) code=${code} signal=${signal}`);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { removeTask, saveTask } from '../../daemon/taskRegistry';
|
||||
import { runHeteroTask } from '../heteroTask';
|
||||
import { cancelHeteroTask, runHeteroTask } from '../heteroTask';
|
||||
|
||||
// ─── Mocks ───
|
||||
|
||||
@@ -249,3 +249,31 @@ describe('runHeteroTask (openclaw)', () => {
|
||||
killSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
describe('cancelHeteroTask', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
for (const key of Object.keys(taskStore)) delete taskStore[key];
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('signals the process group for a registered codex task', async () => {
|
||||
const killSpy = vi.spyOn(process, 'kill').mockImplementation(() => true);
|
||||
taskStore['op-codex'] = {
|
||||
agentType: 'codex',
|
||||
operationId: 'op-codex',
|
||||
pid: 4321,
|
||||
startedAt: '2026-01-01T00:00:00.000Z',
|
||||
taskId: 'op-codex',
|
||||
topicId: 'topic-1',
|
||||
};
|
||||
|
||||
const result = await cancelHeteroTask({ taskId: 'op-codex' });
|
||||
|
||||
expect(result).toBe(JSON.stringify({ pid: 4321, signal: 'SIGINT', taskId: 'op-codex' }));
|
||||
expect(killSpy).toHaveBeenCalledWith(process.platform === 'win32' ? 4321 : -4321, 'SIGINT');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -64,6 +64,19 @@ export interface CancelHeteroTaskParams {
|
||||
taskId: string;
|
||||
}
|
||||
|
||||
function signalTaskProcess(pid: number, signal: NodeJS.Signals): void {
|
||||
if (process.platform === 'win32') {
|
||||
process.kill(pid, signal);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
process.kill(-pid, signal);
|
||||
} catch {
|
||||
process.kill(pid, signal);
|
||||
}
|
||||
}
|
||||
|
||||
async function sendAutoNotify(
|
||||
topicId: string,
|
||||
taskId: string,
|
||||
@@ -320,9 +333,11 @@ export async function cancelHeteroTask(params: CancelHeteroTaskParams): Promise<
|
||||
return JSON.stringify({ message: `No task found with taskId: ${taskId}`, success: false });
|
||||
}
|
||||
|
||||
// Both openclaw and hermes: kill by PID and let the child's close handler send the notify.
|
||||
// Signal the whole process group when available. Local CLI agent runs
|
||||
// (claude-code / codex) can spawn their own tool subprocesses, so a
|
||||
// parent-only signal is not enough.
|
||||
try {
|
||||
process.kill(entry.pid, signal);
|
||||
signalTaskProcess(entry.pid, signal);
|
||||
} catch (err) {
|
||||
// Process already exited — exit handler won't fire; clean up manually.
|
||||
log.warn(
|
||||
|
||||
@@ -56,7 +56,6 @@
|
||||
"@electron-toolkit/utils": "^4.0.0",
|
||||
"@lobechat/chat-adapter-imessage": "workspace:*",
|
||||
"@lobechat/desktop-bridge": "workspace:*",
|
||||
"@lobechat/device-control": "workspace:*",
|
||||
"@lobechat/device-gateway-client": "workspace:*",
|
||||
"@lobechat/device-identity": "workspace:*",
|
||||
"@lobechat/electron-client-ipc": "workspace:*",
|
||||
|
||||
@@ -8,7 +8,6 @@ packages:
|
||||
- '../../packages/electron-client-ipc'
|
||||
- '../../packages/file-loaders'
|
||||
- '../../packages/desktop-bridge'
|
||||
- '../../packages/device-control'
|
||||
- '../../packages/device-gateway-client'
|
||||
- '../../packages/device-identity'
|
||||
- '../../packages/local-file-shell'
|
||||
|
||||
@@ -3,7 +3,6 @@ import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
|
||||
import { type DeviceControlDeps, executeDeviceRpc as runDeviceRpc } from '@lobechat/device-control';
|
||||
import type {
|
||||
AgentRunRequestMessage,
|
||||
GatewayMcpStdioParams,
|
||||
@@ -14,8 +13,11 @@ import type {
|
||||
GetCommandOutputParams,
|
||||
GlobFilesParams,
|
||||
GrepContentParams,
|
||||
InitWorkspaceParams,
|
||||
KillCommandParams,
|
||||
ListLocalFileParams,
|
||||
ListProjectSkillsParams,
|
||||
LocalFilePreviewUrlParams,
|
||||
LocalReadFileParams,
|
||||
LocalReadFilesParams,
|
||||
LocalSearchFilesParams,
|
||||
@@ -28,16 +30,15 @@ import { type ILocalSystemService, LocalSystemExecutionRuntime } from '@lobechat
|
||||
|
||||
import GatewayConnectionService from '@/services/gatewayConnectionSrv';
|
||||
import ImessageBridgeService from '@/services/imessageBridgeSrv';
|
||||
import { createLogger } from '@/utils/logger';
|
||||
|
||||
import GitCtr from './GitCtr';
|
||||
import HeterogeneousAgentCtr from './HeterogeneousAgentCtr';
|
||||
import { ControllerModule, IpcMethod } from './index';
|
||||
import LocalFileCtr from './LocalFileCtr';
|
||||
import McpCtr from './McpCtr';
|
||||
import RemoteServerConfigCtr from './RemoteServerConfigCtr';
|
||||
import ShellCommandCtr from './ShellCommandCtr';
|
||||
|
||||
const logger = createLogger('controllers:GatewayConnectionCtr');
|
||||
import WorkspaceCtr from './WorkspaceCtr';
|
||||
|
||||
/**
|
||||
* Inject the lh-notify protocol into the first turn of a new hetero-agent session.
|
||||
@@ -166,6 +167,14 @@ export default class GatewayConnectionCtr extends ControllerModule {
|
||||
return this.app.getController(LocalFileCtr);
|
||||
}
|
||||
|
||||
private get workspaceCtr() {
|
||||
return this.app.getController(WorkspaceCtr);
|
||||
}
|
||||
|
||||
private get gitCtr() {
|
||||
return this.app.getController(GitCtr);
|
||||
}
|
||||
|
||||
private get shellCommandCtr() {
|
||||
return this.app.getController(ShellCommandCtr);
|
||||
}
|
||||
@@ -344,33 +353,91 @@ export default class GatewayConnectionCtr extends ControllerModule {
|
||||
return this.localSystemRuntime;
|
||||
}
|
||||
|
||||
/**
|
||||
* Platform-specific handlers the shared `@lobechat/device-control` dispatcher
|
||||
* delegates to. Git + workspace-scan methods run inside device-control over
|
||||
* `@lobechat/local-file-shell`; only file preview / index (and preview
|
||||
* approval) are desktop-specific and routed back to the controllers here.
|
||||
*/
|
||||
private get deviceControlDeps(): DeviceControlDeps {
|
||||
return {
|
||||
approveProjectRoot: async (root) => {
|
||||
try {
|
||||
await this.app.localFileProtocolManager.approveIndexedProjectRoot(root);
|
||||
} catch (error) {
|
||||
logger.error(`Failed to approve project preview root ${root}:`, error);
|
||||
}
|
||||
},
|
||||
getLocalFilePreview: (params) => this.localFileCtr.getLocalFilePreview(params),
|
||||
getProjectFileIndex: (params) => this.localFileCtr.getProjectFileIndex(params),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Dispatch a generic server-internal device RPC (not an agent tool call) by
|
||||
* method name. The dispatch logic lives in `@lobechat/device-control` so the
|
||||
* desktop main process and the CLI daemon share one device RPC surface.
|
||||
* method name. Currently only `initWorkspace` (scan the bound project root for
|
||||
* skills + AGENTS.md); add new server-only device methods here.
|
||||
*/
|
||||
private async executeDeviceRpc(method: string, params: unknown): Promise<unknown> {
|
||||
return runDeviceRpc(method, params, this.deviceControlDeps);
|
||||
switch (method) {
|
||||
case 'initWorkspace': {
|
||||
return this.workspaceCtr.initWorkspace(params as InitWorkspaceParams);
|
||||
}
|
||||
|
||||
case 'getGitBranch': {
|
||||
return this.gitCtr.getGitBranch((params as { path: string }).path);
|
||||
}
|
||||
|
||||
case 'getLinkedPullRequest': {
|
||||
return this.gitCtr.getLinkedPullRequest(params as { branch: string; path: string });
|
||||
}
|
||||
|
||||
case 'getGitWorkingTreeStatus': {
|
||||
return this.gitCtr.getGitWorkingTreeStatus((params as { path: string }).path);
|
||||
}
|
||||
|
||||
case 'getGitAheadBehind': {
|
||||
return this.gitCtr.getGitAheadBehind((params as { path: string }).path);
|
||||
}
|
||||
|
||||
case 'listGitBranches': {
|
||||
return this.gitCtr.listGitBranches((params as { path: string }).path);
|
||||
}
|
||||
|
||||
case 'checkoutGitBranch': {
|
||||
return this.gitCtr.checkoutGitBranch(
|
||||
params as { branch: string; create?: boolean; path: string },
|
||||
);
|
||||
}
|
||||
|
||||
case 'pullGitBranch': {
|
||||
return this.gitCtr.pullGitBranch(params as { path: string });
|
||||
}
|
||||
|
||||
case 'pushGitBranch': {
|
||||
return this.gitCtr.pushGitBranch(params as { path: string });
|
||||
}
|
||||
|
||||
case 'getGitWorkingTreePatches': {
|
||||
return this.gitCtr.getGitWorkingTreePatches((params as { path: string }).path);
|
||||
}
|
||||
|
||||
case 'getGitWorkingTreeFiles': {
|
||||
return this.gitCtr.getGitWorkingTreeFiles((params as { path: string }).path);
|
||||
}
|
||||
|
||||
case 'getProjectFileIndex': {
|
||||
return this.localFileCtr.getProjectFileIndex(params as { scope?: string });
|
||||
}
|
||||
|
||||
case 'getLocalFilePreview': {
|
||||
return this.localFileCtr.getLocalFilePreview(params as LocalFilePreviewUrlParams);
|
||||
}
|
||||
|
||||
case 'listProjectSkills': {
|
||||
return this.workspaceCtr.listProjectSkills(params as ListProjectSkillsParams);
|
||||
}
|
||||
|
||||
case 'getGitBranchDiff': {
|
||||
return this.gitCtr.getGitBranchDiff(params as { baseRef?: string; path: string });
|
||||
}
|
||||
|
||||
case 'listGitRemoteBranches': {
|
||||
return this.gitCtr.listGitRemoteBranches((params as { path: string }).path);
|
||||
}
|
||||
|
||||
case 'revertGitFile': {
|
||||
return this.gitCtr.revertGitFile(params as { filePath: string; path: string });
|
||||
}
|
||||
|
||||
case 'statPath': {
|
||||
return this.workspaceCtr.statPath(params as { path: string });
|
||||
}
|
||||
|
||||
default: {
|
||||
throw new Error(`Unknown device RPC method: ${method}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async executeToolCall(
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,53 +1,244 @@
|
||||
import {
|
||||
initWorkspace as runInitWorkspace,
|
||||
listProjectSkills as runListProjectSkills,
|
||||
statPath as runStatPath,
|
||||
type WorkspaceScanDeps,
|
||||
} from '@lobechat/device-control';
|
||||
import { readdir, readFile, stat } from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
|
||||
import {
|
||||
type InitWorkspaceParams,
|
||||
type InitWorkspaceResult,
|
||||
type ListProjectSkillsParams,
|
||||
type ListProjectSkillsResult,
|
||||
type ProjectSkillItem,
|
||||
} from '@lobechat/electron-client-ipc';
|
||||
|
||||
import { detectRepoType } from '@/utils/git';
|
||||
import { createLogger } from '@/utils/logger';
|
||||
|
||||
import { ControllerModule, IpcMethod } from './index';
|
||||
|
||||
const logger = createLogger('controllers:WorkspaceCtr');
|
||||
|
||||
const SKILL_FRONTMATTER_RE = /^---\r?\n([\s\S]*?)\r?\n---/;
|
||||
|
||||
// Cap recursion to guard against pathological directory trees.
|
||||
const MAX_SKILL_FILE_COUNT = 1000;
|
||||
|
||||
const toPosixRelativePath = (filePath: string) => filePath.split(path.sep).join('/');
|
||||
|
||||
const listSkillFilesRecursive = async (dir: string): Promise<string[]> => {
|
||||
const results: string[] = [];
|
||||
const stack: string[] = [dir];
|
||||
|
||||
while (stack.length > 0 && results.length < MAX_SKILL_FILE_COUNT) {
|
||||
const current = stack.pop()!;
|
||||
let entries;
|
||||
try {
|
||||
entries = await readdir(current, { withFileTypes: true });
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
for (const entry of entries) {
|
||||
if (entry.name.startsWith('.')) continue;
|
||||
const full = path.join(current, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
stack.push(full);
|
||||
} else if (entry.isFile()) {
|
||||
results.push(toPosixRelativePath(path.relative(dir, full)));
|
||||
if (results.length >= MAX_SKILL_FILE_COUNT) break;
|
||||
}
|
||||
}
|
||||
}
|
||||
return results.sort();
|
||||
};
|
||||
|
||||
// Parse a minimal YAML frontmatter block for SKILL.md files.
|
||||
// Only handles `key: value` lines; multi-line block scalars fall back to the first line.
|
||||
const parseSkillFrontmatter = (raw: string): Record<string, string> => {
|
||||
const match = raw.match(SKILL_FRONTMATTER_RE);
|
||||
if (!match) return {};
|
||||
|
||||
const fields: Record<string, string> = {};
|
||||
for (const line of match[1].split(/\r?\n/)) {
|
||||
const colonIdx = line.indexOf(':');
|
||||
if (colonIdx === -1) continue;
|
||||
const key = line.slice(0, colonIdx).trim();
|
||||
if (!key || key.startsWith('#')) continue;
|
||||
let value = line.slice(colonIdx + 1).trim();
|
||||
if (value.startsWith('|') || value.startsWith('>')) continue;
|
||||
if (
|
||||
(value.startsWith('"') && value.endsWith('"')) ||
|
||||
(value.startsWith("'") && value.endsWith("'"))
|
||||
) {
|
||||
value = value.slice(1, -1);
|
||||
}
|
||||
fields[key] = value;
|
||||
}
|
||||
return fields;
|
||||
};
|
||||
|
||||
/**
|
||||
* WorkspaceCtr
|
||||
*
|
||||
* Thin IPC layer over `@lobechat/device-control`'s workspace-scan helpers
|
||||
* (skills discovery under `.agents/skills` / `.claude/skills` + project-root
|
||||
* instructions). The scan logic is shared with the device-control RPC dispatch
|
||||
* so the local desktop IPC path, the remote device RPC, and the CLI all run
|
||||
* identical scans; the desktop-only preview-protocol approval is injected here.
|
||||
* Owns "project workspace" scanning: discovering agent skills (`.agents/skills`
|
||||
* / `.claude/skills`) and project-root instructions (`AGENTS.md` / `CLAUDE.md`)
|
||||
* under a bound project directory. Split out of LocalFileCtr so the
|
||||
* workspace/agent-config concern is distinct from generic local file ops.
|
||||
*/
|
||||
export default class WorkspaceCtr extends ControllerModule {
|
||||
static override readonly groupName = 'workspace';
|
||||
|
||||
private get scanDeps(): WorkspaceScanDeps {
|
||||
return { approveProjectRoot: (root) => this.approveProjectRootForPreview(root) };
|
||||
/**
|
||||
* Scan one skill source directory (e.g. `.agents/skills`) under `root` and
|
||||
* return parsed frontmatter for each `SKILL.md`. Returns `[]` when the source
|
||||
* directory is absent or unreadable. Unsorted — callers sort/merge.
|
||||
*/
|
||||
private async scanSkillsInSource(
|
||||
root: string,
|
||||
source: ProjectSkillItem['source'],
|
||||
): Promise<ProjectSkillItem[]> {
|
||||
const dir = path.join(root, source);
|
||||
let entries;
|
||||
try {
|
||||
entries = await readdir(dir, { withFileTypes: true });
|
||||
} catch {
|
||||
// Directory does not exist or is not readable.
|
||||
return [];
|
||||
}
|
||||
|
||||
const skills = await Promise.all(
|
||||
entries
|
||||
.filter((entry) => entry.isDirectory() || entry.isSymbolicLink())
|
||||
.map(async (entry) => {
|
||||
const skillDir = path.join(dir, entry.name);
|
||||
const skillFile = path.join(skillDir, 'SKILL.md');
|
||||
try {
|
||||
const raw = await readFile(skillFile, 'utf8');
|
||||
const fields = parseSkillFrontmatter(raw);
|
||||
const files = await listSkillFilesRecursive(skillDir);
|
||||
return {
|
||||
description: fields.description || undefined,
|
||||
fileCount: files.length,
|
||||
files,
|
||||
name: fields.name || entry.name,
|
||||
path: skillFile,
|
||||
skillDir,
|
||||
source,
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
return skills.filter((skill): skill is ProjectSkillItem => skill !== null);
|
||||
}
|
||||
|
||||
/**
|
||||
* Scan agent skill directories under the project root and return parsed
|
||||
* frontmatter for each SKILL.md. Used by the hetero agent's working sidebar
|
||||
* to surface skills available in the current project. Returns the first
|
||||
* source directory that yields any skills (`.agents/skills` wins).
|
||||
*/
|
||||
@IpcMethod()
|
||||
async listProjectSkills(params: ListProjectSkillsParams): Promise<ListProjectSkillsResult> {
|
||||
return runListProjectSkills(params, this.scanDeps);
|
||||
const root = params.scope;
|
||||
const sources = ['.agents/skills', '.claude/skills'] as const;
|
||||
|
||||
for (const source of sources) {
|
||||
const skills = (await this.scanSkillsInSource(root, source)).sort((a, b) =>
|
||||
a.name.localeCompare(b.name),
|
||||
);
|
||||
|
||||
if (skills.length > 0) {
|
||||
await this.approveProjectRootForPreview(root);
|
||||
return { root, skills, source };
|
||||
}
|
||||
}
|
||||
|
||||
return { root, skills: [], source: null };
|
||||
}
|
||||
|
||||
/**
|
||||
* One-call "workspace init" scan of a bound project directory: merge the
|
||||
* project skills from BOTH `.agents/skills` and `.claude/skills` (deduped by
|
||||
* name, `.agents/skills` winning) and read the project-root agent
|
||||
* instructions file (`AGENTS.md`, else `CLAUDE.md`). Driven server-side at run
|
||||
* start via the generic device RPC (not an LLM-visible tool) and cached onto
|
||||
* `devices.workingDirs[].workspace`.
|
||||
*
|
||||
* Approves the root for the `lobe-file://` preview protocol (same as
|
||||
* `listProjectSkills`) so the user can later click through to the scanned
|
||||
* skills / instructions in the UI.
|
||||
*/
|
||||
@IpcMethod()
|
||||
async initWorkspace(params: InitWorkspaceParams): Promise<InitWorkspaceResult> {
|
||||
return runInitWorkspace(params, this.scanDeps);
|
||||
const root = params.scope;
|
||||
const sources = ['.agents/skills', '.claude/skills'] as const;
|
||||
|
||||
const seen = new Set<string>();
|
||||
const skills: ProjectSkillItem[] = [];
|
||||
for (const source of sources) {
|
||||
for (const skill of await this.scanSkillsInSource(root, source)) {
|
||||
if (seen.has(skill.name)) continue;
|
||||
seen.add(skill.name);
|
||||
skills.push(skill);
|
||||
}
|
||||
}
|
||||
skills.sort((a, b) => a.name.localeCompare(b.name));
|
||||
|
||||
const instructions = await this.readWorkspaceInstructions(root);
|
||||
|
||||
// Approve regardless of what was found — the run is now bound to this root,
|
||||
// so any later click-through to it should resolve through the preview
|
||||
// protocol even if the project carries neither skills nor instructions.
|
||||
await this.approveProjectRootForPreview(root);
|
||||
|
||||
return { instructions, root, skills };
|
||||
}
|
||||
|
||||
/**
|
||||
* Check whether a path exists on this device and is a directory, plus its git
|
||||
* repo type (`git` / `github` / none). Used to validate a manually-entered
|
||||
* working directory from a web / remote client (which can't browse this
|
||||
* device's filesystem) before binding it, and to render the right dir icon.
|
||||
*/
|
||||
@IpcMethod()
|
||||
async statPath(params: {
|
||||
path: string;
|
||||
}): Promise<{ exists: boolean; isDirectory: boolean; repoType?: 'git' | 'github' }> {
|
||||
return runStatPath(params);
|
||||
try {
|
||||
const stats = await stat(params.path);
|
||||
if (!stats.isDirectory()) return { exists: true, isDirectory: false };
|
||||
const repoType = await detectRepoType(params.path);
|
||||
return { exists: true, isDirectory: true, repoType };
|
||||
} catch {
|
||||
return { exists: false, isDirectory: false };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the project-root agent instructions files. Collects every present
|
||||
* candidate (`AGENTS.md`, then `CLAUDE.md`) rather than first-match, since both
|
||||
* can coexist. Each body is capped so a pathologically large file can't bloat
|
||||
* the cached `workingDirs` payload or the injected system role.
|
||||
*/
|
||||
private async readWorkspaceInstructions(
|
||||
root: string,
|
||||
): Promise<InitWorkspaceResult['instructions']> {
|
||||
const MAX_INSTRUCTIONS_BYTES = 64 * 1024;
|
||||
const candidates = ['AGENTS.md', 'CLAUDE.md'] as const;
|
||||
|
||||
const instructions: InitWorkspaceResult['instructions'] = [];
|
||||
for (const source of candidates) {
|
||||
try {
|
||||
const raw = await readFile(path.join(root, source), 'utf8');
|
||||
const content =
|
||||
raw.length > MAX_INSTRUCTIONS_BYTES ? raw.slice(0, MAX_INSTRUCTIONS_BYTES) : raw;
|
||||
instructions.push({ content, source });
|
||||
} catch {
|
||||
// File absent or unreadable; skip it.
|
||||
}
|
||||
}
|
||||
|
||||
return instructions;
|
||||
}
|
||||
|
||||
private async approveProjectRootForPreview(root: string) {
|
||||
|
||||
+14
-2
@@ -1,6 +1,15 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { dequoteGitPath, quoteGitPath } from '../workingTree';
|
||||
import { dequoteGitPath, quoteGitPath } from '../GitCtr';
|
||||
|
||||
vi.mock('@/utils/logger', () => ({
|
||||
createLogger: () => ({
|
||||
debug: vi.fn(),
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
describe('quoteGitPath', () => {
|
||||
it('leaves plain ASCII paths unquoted (including spaces)', () => {
|
||||
@@ -24,6 +33,8 @@ describe('quoteGitPath', () => {
|
||||
});
|
||||
|
||||
it('puts the prefix inside the quotes', () => {
|
||||
// Real git output for `git diff` of a tab-containing file:
|
||||
// diff --git "a/with\there" "b/with\there"
|
||||
expect(quoteGitPath('a/', 'with\there')).toBe('"a/with\\there"');
|
||||
expect(quoteGitPath('b/', 'with\there')).toBe('"b/with\\there"');
|
||||
});
|
||||
@@ -40,6 +51,7 @@ describe('quoteGitPath', () => {
|
||||
];
|
||||
for (const original of cases) {
|
||||
const quoted = quoteGitPath('b/', original);
|
||||
// Strip the surrounding quotes + b/ prefix, then de-escape.
|
||||
expect(quoted.startsWith('"b/')).toBe(true);
|
||||
expect(quoted.endsWith('"')).toBe(true);
|
||||
const stripped = quoted.slice(1, -1).slice('b/'.length);
|
||||
@@ -132,14 +132,6 @@ describe('formatErrorForState', () => {
|
||||
expect(result.countAsFailure).toBeUndefined();
|
||||
expect(result.numericId).toBeUndefined();
|
||||
});
|
||||
|
||||
it('classifies a raw Drizzle "Failed query" Error via its message instead of a bare 500', () => {
|
||||
const result = formatErrorForState(new Error('Failed query: rollback\nparams: '));
|
||||
|
||||
expect(result.type).toBe(AgentRuntimeErrorType.DatabasePersistError);
|
||||
expect(result.numericId).toBe(7004);
|
||||
expect(result.attribution).toBe('harness');
|
||||
});
|
||||
});
|
||||
|
||||
describe('ProviderBizError refinement', () => {
|
||||
|
||||
@@ -9,16 +9,10 @@ import { KnowledgeBaseModel } from '@/database/models/knowledgeBase';
|
||||
import { SessionModel } from '@/database/models/session';
|
||||
import { UserModel } from '@/database/models/user';
|
||||
import { AgentService } from '@/server/services/agent';
|
||||
import { EditLockService } from '@/server/services/editLock';
|
||||
import { publishResourceEvent } from '@/server/services/resourceEvents';
|
||||
import { KnowledgeType } from '@/types/knowledgeBase';
|
||||
|
||||
import { agentRouter } from '../agent';
|
||||
|
||||
vi.mock('@/server/services/resourceEvents', () => ({ publishResourceEvent: vi.fn() }));
|
||||
|
||||
const publishResourceEventMock = vi.mocked(publishResourceEvent);
|
||||
|
||||
vi.mock('@/database/models/user', () => ({
|
||||
UserModel: {
|
||||
findById: vi.fn(),
|
||||
@@ -335,122 +329,4 @@ describe('agentRouter', () => {
|
||||
expect(agentModelMock.update).toHaveBeenCalledWith(mockInput.id, { pinned: false });
|
||||
});
|
||||
});
|
||||
|
||||
describe('edit lock', () => {
|
||||
const wsCtx = () => ({ ...mockCtx, workspaceId: 'ws-1' });
|
||||
|
||||
describe('updateAgentConfig write guard', () => {
|
||||
it('rejects the update when another member holds the lock', async () => {
|
||||
agentServiceMock.updateAgentConfig = vi.fn().mockResolvedValue({ id: 'agent-1' });
|
||||
vi.spyOn(EditLockService.prototype, 'getBlockingHolder').mockResolvedValue('other-user');
|
||||
|
||||
const caller = agentRouter.createCaller(wsCtx());
|
||||
|
||||
await expect(
|
||||
caller.updateAgentConfig({ agentId: 'agent-1', value: { systemRole: 'x' } }),
|
||||
).rejects.toMatchObject({ code: 'CONFLICT' });
|
||||
expect(agentServiceMock.updateAgentConfig).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('allows the update when no other member holds the lock', async () => {
|
||||
agentServiceMock.updateAgentConfig = vi.fn().mockResolvedValue({ id: 'agent-1' });
|
||||
vi.spyOn(EditLockService.prototype, 'getBlockingHolder').mockResolvedValue(null);
|
||||
|
||||
const caller = agentRouter.createCaller(wsCtx());
|
||||
await caller.updateAgentConfig({ agentId: 'agent-1', value: { systemRole: 'x' } });
|
||||
|
||||
expect(agentServiceMock.updateAgentConfig).toHaveBeenCalledWith('agent-1', {
|
||||
systemRole: 'x',
|
||||
});
|
||||
});
|
||||
|
||||
it('does not check the lock for personal (non-workspace) agents', async () => {
|
||||
agentServiceMock.updateAgentConfig = vi.fn().mockResolvedValue({ id: 'agent-1' });
|
||||
const guardSpy = vi.spyOn(EditLockService.prototype, 'getBlockingHolder');
|
||||
|
||||
const caller = agentRouter.createCaller(mockCtx);
|
||||
await caller.updateAgentConfig({ agentId: 'agent-1', value: { systemRole: 'x' } });
|
||||
|
||||
expect(guardSpy).not.toHaveBeenCalled();
|
||||
expect(agentServiceMock.updateAgentConfig).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('acquireAgentLock', () => {
|
||||
it('returns unlocked without touching the lock service for personal agents', async () => {
|
||||
const acquireSpy = vi.spyOn(EditLockService.prototype, 'acquire');
|
||||
|
||||
const caller = agentRouter.createCaller(mockCtx);
|
||||
const result = await caller.acquireAgentLock({ agentId: 'agent-1' });
|
||||
|
||||
expect(result).toEqual({ expiresAt: null, holderId: null, lockedByOther: false });
|
||||
expect(acquireSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('broadcasts lock.changed on a holder edge (first claim)', async () => {
|
||||
vi.spyOn(EditLockService.prototype, 'getActiveHolder').mockResolvedValue(undefined);
|
||||
vi.spyOn(EditLockService.prototype, 'acquire').mockResolvedValue({
|
||||
expiresAt: new Date(),
|
||||
holderId: userId,
|
||||
lockedByOther: false,
|
||||
});
|
||||
|
||||
const caller = agentRouter.createCaller(wsCtx());
|
||||
await caller.acquireAgentLock({ agentId: 'agent-1' });
|
||||
|
||||
expect(publishResourceEventMock).toHaveBeenCalledWith(
|
||||
{ id: 'agent-1', type: 'agent' },
|
||||
expect.objectContaining({ data: { holderId: userId }, type: 'lock.changed' }),
|
||||
);
|
||||
});
|
||||
|
||||
it('does NOT broadcast on a steady-state heartbeat (same holder)', async () => {
|
||||
vi.spyOn(EditLockService.prototype, 'getActiveHolder').mockResolvedValue(userId);
|
||||
vi.spyOn(EditLockService.prototype, 'acquire').mockResolvedValue({
|
||||
expiresAt: new Date(),
|
||||
holderId: userId,
|
||||
lockedByOther: false,
|
||||
});
|
||||
|
||||
const caller = agentRouter.createCaller(wsCtx());
|
||||
await caller.acquireAgentLock({ agentId: 'agent-1' });
|
||||
|
||||
expect(publishResourceEventMock).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('getAgentLock', () => {
|
||||
it('reports another member as the holder', async () => {
|
||||
vi.spyOn(EditLockService.prototype, 'getActiveHolder').mockResolvedValue('other-user');
|
||||
|
||||
const caller = agentRouter.createCaller(wsCtx());
|
||||
const result = await caller.getAgentLock({ agentId: 'agent-1' });
|
||||
|
||||
expect(result).toEqual({ expiresAt: null, holderId: 'other-user', lockedByOther: true });
|
||||
});
|
||||
});
|
||||
|
||||
describe('releaseAgentLock', () => {
|
||||
it('broadcasts unlocked only when it actually freed the lock', async () => {
|
||||
vi.spyOn(EditLockService.prototype, 'release').mockResolvedValue(true);
|
||||
|
||||
const caller = agentRouter.createCaller(wsCtx());
|
||||
await caller.releaseAgentLock({ agentId: 'agent-1' });
|
||||
|
||||
expect(publishResourceEventMock).toHaveBeenCalledWith(
|
||||
{ id: 'agent-1', type: 'agent' },
|
||||
expect.objectContaining({ data: { holderId: null }, type: 'lock.changed' }),
|
||||
);
|
||||
});
|
||||
|
||||
it('does NOT broadcast when the lease expired / was taken over', async () => {
|
||||
vi.spyOn(EditLockService.prototype, 'release').mockResolvedValue(false);
|
||||
|
||||
const caller = agentRouter.createCaller(wsCtx());
|
||||
await caller.releaseAgentLock({ agentId: 'agent-1' });
|
||||
|
||||
expect(publishResourceEventMock).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -7,15 +7,9 @@ import * as ChatGroupModelModule from '@/database/models/chatGroup';
|
||||
import * as UserModelModule from '@/database/models/user';
|
||||
import * as AgentGroupRepoModule from '@/database/repositories/agentGroup';
|
||||
import * as ChatGroupServiceModule from '@/server/services/agentGroup';
|
||||
import { EditLockService } from '@/server/services/editLock';
|
||||
import { publishResourceEvent } from '@/server/services/resourceEvents';
|
||||
|
||||
import { agentGroupRouter } from '../agentGroup';
|
||||
|
||||
vi.mock('@/server/services/resourceEvents', () => ({ publishResourceEvent: vi.fn() }));
|
||||
|
||||
const publishResourceEventMock = vi.mocked(publishResourceEvent);
|
||||
|
||||
describe('agentGroupRouter', () => {
|
||||
const userId = 'testUserId';
|
||||
let mockCtx: any;
|
||||
@@ -445,126 +439,4 @@ describe('agentGroupRouter', () => {
|
||||
expect(result).toEqual(mockUpdatedGroup);
|
||||
});
|
||||
});
|
||||
|
||||
describe('edit lock', () => {
|
||||
const wsCtx = () => ({ serverDB: {}, userId, workspaceId: 'ws-1' });
|
||||
|
||||
describe('updateGroup write guard', () => {
|
||||
it('rejects the update when another member holds the lock', async () => {
|
||||
vi.spyOn(EditLockService.prototype, 'getBlockingHolder').mockResolvedValue('other-user');
|
||||
|
||||
const caller = agentGroupRouter.createCaller(wsCtx());
|
||||
|
||||
await expect(
|
||||
caller.updateGroup({ id: 'group-1', value: { title: 'New' } }),
|
||||
).rejects.toMatchObject({ code: 'CONFLICT' });
|
||||
expect(chatGroupModelMock.update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('allows the update when no other member holds the lock', async () => {
|
||||
vi.spyOn(EditLockService.prototype, 'getBlockingHolder').mockResolvedValue(null);
|
||||
chatGroupModelMock.update.mockResolvedValue({ id: 'group-1' });
|
||||
|
||||
const caller = agentGroupRouter.createCaller(wsCtx());
|
||||
await caller.updateGroup({ id: 'group-1', value: { title: 'New' } });
|
||||
|
||||
expect(chatGroupModelMock.update).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not check the lock for personal (non-workspace) groups', async () => {
|
||||
const guardSpy = vi.spyOn(EditLockService.prototype, 'getBlockingHolder');
|
||||
chatGroupModelMock.update.mockResolvedValue({ id: 'group-1' });
|
||||
|
||||
const caller = agentGroupRouter.createCaller(mockCtx);
|
||||
await caller.updateGroup({ id: 'group-1', value: { title: 'New' } });
|
||||
|
||||
expect(guardSpy).not.toHaveBeenCalled();
|
||||
expect(chatGroupModelMock.update).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('acquireGroupLock', () => {
|
||||
it('returns unlocked without touching the lock service for personal groups', async () => {
|
||||
const acquireSpy = vi.spyOn(EditLockService.prototype, 'acquire');
|
||||
|
||||
const caller = agentGroupRouter.createCaller(mockCtx);
|
||||
const result = await caller.acquireGroupLock({ id: 'group-1' });
|
||||
|
||||
expect(result).toEqual({ expiresAt: null, holderId: null, lockedByOther: false });
|
||||
expect(acquireSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('broadcasts lock.changed on a holder edge (first claim)', async () => {
|
||||
vi.spyOn(EditLockService.prototype, 'getActiveHolder').mockResolvedValue(undefined);
|
||||
vi.spyOn(EditLockService.prototype, 'acquire').mockResolvedValue({
|
||||
expiresAt: new Date(),
|
||||
holderId: userId,
|
||||
lockedByOther: false,
|
||||
});
|
||||
|
||||
const caller = agentGroupRouter.createCaller(wsCtx());
|
||||
await caller.acquireGroupLock({ id: 'group-1' });
|
||||
|
||||
expect(publishResourceEventMock).toHaveBeenCalledWith(
|
||||
{ id: 'group-1', type: 'chatGroup' },
|
||||
expect.objectContaining({ data: { holderId: userId }, type: 'lock.changed' }),
|
||||
);
|
||||
});
|
||||
|
||||
it('does NOT broadcast on a steady-state heartbeat (same holder)', async () => {
|
||||
vi.spyOn(EditLockService.prototype, 'getActiveHolder').mockResolvedValue(userId);
|
||||
vi.spyOn(EditLockService.prototype, 'acquire').mockResolvedValue({
|
||||
expiresAt: new Date(),
|
||||
holderId: userId,
|
||||
lockedByOther: false,
|
||||
});
|
||||
|
||||
const caller = agentGroupRouter.createCaller(wsCtx());
|
||||
await caller.acquireGroupLock({ id: 'group-1' });
|
||||
|
||||
expect(publishResourceEventMock).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('getGroupLock', () => {
|
||||
it('reports another member as the holder', async () => {
|
||||
vi.spyOn(EditLockService.prototype, 'getActiveHolder').mockResolvedValue('other-user');
|
||||
|
||||
const caller = agentGroupRouter.createCaller(wsCtx());
|
||||
const result = await caller.getGroupLock({ id: 'group-1' });
|
||||
|
||||
expect(result).toEqual({ expiresAt: null, holderId: 'other-user', lockedByOther: true });
|
||||
});
|
||||
|
||||
it('returns unlocked for personal groups', async () => {
|
||||
const caller = agentGroupRouter.createCaller(mockCtx);
|
||||
const result = await caller.getGroupLock({ id: 'group-1' });
|
||||
|
||||
expect(result).toEqual({ expiresAt: null, holderId: null, lockedByOther: false });
|
||||
});
|
||||
});
|
||||
|
||||
describe('releaseGroupLock', () => {
|
||||
it('broadcasts unlocked only when it actually freed the lock', async () => {
|
||||
vi.spyOn(EditLockService.prototype, 'release').mockResolvedValue(true);
|
||||
|
||||
const caller = agentGroupRouter.createCaller(wsCtx());
|
||||
await caller.releaseGroupLock({ id: 'group-1' });
|
||||
|
||||
expect(publishResourceEventMock).toHaveBeenCalledWith(
|
||||
{ id: 'group-1', type: 'chatGroup' },
|
||||
expect.objectContaining({ data: { holderId: null }, type: 'lock.changed' }),
|
||||
);
|
||||
});
|
||||
|
||||
it('does NOT broadcast when the lease expired / was taken over', async () => {
|
||||
vi.spyOn(EditLockService.prototype, 'release').mockResolvedValue(false);
|
||||
|
||||
const caller = agentGroupRouter.createCaller(wsCtx());
|
||||
await caller.releaseGroupLock({ id: 'group-1' });
|
||||
|
||||
expect(publishResourceEventMock).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -9,6 +9,11 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { aiAgentRouter } from '../aiAgent';
|
||||
import { cleanupTestUser, createTestUser } from './integration/setup';
|
||||
|
||||
const { mockExecuteToolCall, mockSandboxCallTool } = vi.hoisted(() => ({
|
||||
mockExecuteToolCall: vi.fn(),
|
||||
mockSandboxCallTool: vi.fn(),
|
||||
}));
|
||||
|
||||
// Mock getServerDB to return our test database instance
|
||||
let testDB: LobeChatDatabase;
|
||||
vi.mock('@/database/core/db-adaptor', () => ({
|
||||
@@ -29,6 +34,18 @@ vi.mock('@/server/services/aiChat', () => ({
|
||||
AiChatService: vi.fn().mockImplementation(() => ({})),
|
||||
}));
|
||||
|
||||
vi.mock('@/server/services/deviceGateway', () => ({
|
||||
deviceGateway: {
|
||||
executeToolCall: mockExecuteToolCall,
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('@/server/services/sandbox', () => ({
|
||||
createSandboxService: vi.fn(() => ({
|
||||
callTool: mockSandboxCallTool,
|
||||
})),
|
||||
}));
|
||||
|
||||
describe('aiAgentRouter.interruptTask', () => {
|
||||
let serverDB: LobeChatDatabase;
|
||||
let userId: string;
|
||||
@@ -43,6 +60,10 @@ describe('aiAgentRouter.interruptTask', () => {
|
||||
userId = await createTestUser(serverDB);
|
||||
mockInterruptOperation.mockReset();
|
||||
mockInterruptOperation.mockResolvedValue(true);
|
||||
mockExecuteToolCall.mockReset();
|
||||
mockExecuteToolCall.mockResolvedValue({ success: true });
|
||||
mockSandboxCallTool.mockReset();
|
||||
mockSandboxCallTool.mockResolvedValue({ success: true });
|
||||
|
||||
// Create test agent
|
||||
const [agent] = await serverDB
|
||||
@@ -203,6 +224,104 @@ describe('aiAgentRouter.interruptTask', () => {
|
||||
|
||||
expect(updatedThread.status).toBe(ThreadStatus.Cancel);
|
||||
});
|
||||
|
||||
it('should dispatch cancelHeteroTask for a device-dispatched codex operation', async () => {
|
||||
await serverDB
|
||||
.update(topics)
|
||||
.set({
|
||||
metadata: {
|
||||
runningOperation: {
|
||||
assistantMessageId: 'assistant-msg-1',
|
||||
deviceId: 'device-1',
|
||||
heteroType: 'codex',
|
||||
operationId: 'op-codex',
|
||||
},
|
||||
},
|
||||
})
|
||||
.where(eq(topics.id, testTopicId));
|
||||
|
||||
const caller = aiAgentRouter.createCaller(createTestContext());
|
||||
|
||||
const result = await caller.interruptTask({
|
||||
operationId: 'op-codex',
|
||||
topicId: testTopicId,
|
||||
});
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(mockExecuteToolCall).toHaveBeenCalledWith(
|
||||
{ deviceId: 'device-1', userId },
|
||||
{
|
||||
apiName: 'cancelHeteroTask',
|
||||
arguments: JSON.stringify({ signal: 'SIGINT', taskId: 'op-codex' }),
|
||||
identifier: 'cancelHeteroTask',
|
||||
},
|
||||
5000,
|
||||
);
|
||||
|
||||
const [updatedTopic] = await serverDB.select().from(topics).where(eq(topics.id, testTopicId));
|
||||
expect(updatedTopic.metadata?.runningOperation?.cancelRequestedAt).toBeDefined();
|
||||
});
|
||||
|
||||
it('should kill the sandbox background command for a sandbox codex operation', async () => {
|
||||
await serverDB
|
||||
.update(topics)
|
||||
.set({
|
||||
metadata: {
|
||||
runningOperation: {
|
||||
assistantMessageId: 'assistant-msg-1',
|
||||
heteroType: 'codex',
|
||||
operationId: 'op-sandbox',
|
||||
sandboxCommandId: 'cmd-1',
|
||||
},
|
||||
},
|
||||
})
|
||||
.where(eq(topics.id, testTopicId));
|
||||
|
||||
const caller = aiAgentRouter.createCaller(createTestContext());
|
||||
|
||||
const result = await caller.interruptTask({
|
||||
operationId: 'op-sandbox',
|
||||
topicId: testTopicId,
|
||||
});
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(mockSandboxCallTool).toHaveBeenCalledWith('killCommand', { commandId: 'cmd-1' });
|
||||
|
||||
const [updatedTopic] = await serverDB.select().from(topics).where(eq(topics.id, testTopicId));
|
||||
expect(updatedTopic.metadata?.runningOperation?.cancelRequestedAt).toBeDefined();
|
||||
});
|
||||
|
||||
it('should not cancel a topic runningOperation that belongs to another operation', async () => {
|
||||
await serverDB
|
||||
.update(topics)
|
||||
.set({
|
||||
metadata: {
|
||||
runningOperation: {
|
||||
assistantMessageId: 'assistant-msg-current',
|
||||
deviceId: 'device-current',
|
||||
heteroType: 'codex',
|
||||
operationId: 'op-current',
|
||||
sandboxCommandId: 'cmd-current',
|
||||
},
|
||||
},
|
||||
})
|
||||
.where(eq(topics.id, testTopicId));
|
||||
|
||||
const caller = aiAgentRouter.createCaller(createTestContext());
|
||||
|
||||
const result = await caller.interruptTask({
|
||||
operationId: 'op-stale',
|
||||
topicId: testTopicId,
|
||||
});
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(mockExecuteToolCall).not.toHaveBeenCalled();
|
||||
expect(mockSandboxCallTool).not.toHaveBeenCalled();
|
||||
|
||||
const [updatedTopic] = await serverDB.select().from(topics).where(eq(topics.id, testTopicId));
|
||||
expect(updatedTopic.metadata?.runningOperation?.cancelRequestedAt).toBeUndefined();
|
||||
expect(updatedTopic.metadata?.runningOperation?.operationId).toBe('op-current');
|
||||
});
|
||||
});
|
||||
|
||||
describe('interrupt failure handling', () => {
|
||||
|
||||
@@ -17,8 +17,6 @@ import { workspaceMembers } from '@/database/schemas';
|
||||
import { router } from '@/libs/trpc/lambda';
|
||||
import { serverDatabase } from '@/libs/trpc/lambda/middleware';
|
||||
import { AgentService } from '@/server/services/agent';
|
||||
import { EditLockService } from '@/server/services/editLock';
|
||||
import { publishResourceEvent } from '@/server/services/resourceEvents';
|
||||
import { TransferErrorCode } from '@/types/transferError';
|
||||
|
||||
const agentProcedure = wsCompatProcedure.use(serverDatabase).use(async (opts) => {
|
||||
@@ -30,7 +28,6 @@ const agentProcedure = wsCompatProcedure.use(serverDatabase).use(async (opts) =>
|
||||
agentModel: new AgentModel(ctx.serverDB, ctx.userId, wsId),
|
||||
agentService: new AgentService(ctx.serverDB, ctx.userId, wsId),
|
||||
chatGroupModel: new ChatGroupModel(ctx.serverDB, ctx.userId, wsId),
|
||||
editLockService: new EditLockService(ctx.userId),
|
||||
fileModel: new FileModel(ctx.serverDB, ctx.userId, wsId),
|
||||
knowledgeBaseModel: new KnowledgeBaseModel(ctx.serverDB, ctx.userId, wsId),
|
||||
sessionModel: new SessionModel(ctx.serverDB, ctx.userId, wsId),
|
||||
@@ -443,19 +440,6 @@ export const agentRouter = router({
|
||||
}),
|
||||
)
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
// Collaborative edit lock: reject writes to a workspace agent another
|
||||
// member is actively editing. Inert until a client acquires the lock.
|
||||
if (ctx.workspaceId) {
|
||||
const blockedBy = await ctx.editLockService.getBlockingHolder('agent', input.agentId);
|
||||
if (blockedBy) {
|
||||
throw new TRPCError({
|
||||
cause: { data: { code: 'DocumentLocked' } },
|
||||
code: 'CONFLICT',
|
||||
message: 'Agent is being edited by another user',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Use AgentService to update and return the updated agent data
|
||||
return ctx.agentService.updateAgentConfig(input.agentId, input.value);
|
||||
}),
|
||||
@@ -474,48 +458,4 @@ export const agentRouter = router({
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
return ctx.agentModel.update(input.id, { pinned: input.pinned });
|
||||
}),
|
||||
|
||||
acquireAgentLock: agentProcedure
|
||||
.use(withScopedPermission('agent:update'))
|
||||
.input(z.object({ agentId: z.string() }))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
if (!ctx.workspaceId) return { expiresAt: null, holderId: null, lockedByOther: false };
|
||||
const prev = await ctx.editLockService.getActiveHolder('agent', input.agentId);
|
||||
const result = await ctx.editLockService.acquire('agent', input.agentId);
|
||||
if ((result.holderId ?? null) !== (prev ?? null)) {
|
||||
void publishResourceEvent(
|
||||
{ id: input.agentId, type: 'agent' },
|
||||
{ actorId: ctx.userId, data: { holderId: result.holderId }, type: 'lock.changed' },
|
||||
);
|
||||
}
|
||||
return result;
|
||||
}),
|
||||
|
||||
getAgentLock: agentProcedure
|
||||
.use(withScopedPermission('agent:update'))
|
||||
.input(z.object({ agentId: z.string() }))
|
||||
.query(async ({ ctx, input }) => {
|
||||
if (!ctx.workspaceId) return { expiresAt: null, holderId: null, lockedByOther: false };
|
||||
const holder = await ctx.editLockService.getActiveHolder('agent', input.agentId);
|
||||
return {
|
||||
expiresAt: null,
|
||||
holderId: holder ?? null,
|
||||
lockedByOther: Boolean(holder) && holder !== ctx.userId,
|
||||
};
|
||||
}),
|
||||
|
||||
releaseAgentLock: agentProcedure
|
||||
.use(withScopedPermission('agent:update'))
|
||||
.input(z.object({ agentId: z.string() }))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
if (!ctx.workspaceId) return;
|
||||
// Only broadcast "unlocked" when we actually released our own lock — if the
|
||||
// lease expired and another member took over, the lock is still held.
|
||||
const released = await ctx.editLockService.release('agent', input.agentId);
|
||||
if (!released) return;
|
||||
void publishResourceEvent(
|
||||
{ id: input.agentId, type: 'agent' },
|
||||
{ actorId: ctx.userId, data: { holderId: null }, type: 'lock.changed' },
|
||||
);
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -14,8 +14,6 @@ import { type ChatGroupConfig } from '@/database/types/chatGroup';
|
||||
import { router } from '@/libs/trpc/lambda';
|
||||
import { serverDatabase } from '@/libs/trpc/lambda/middleware';
|
||||
import { AgentGroupService } from '@/server/services/agentGroup';
|
||||
import { EditLockService } from '@/server/services/editLock';
|
||||
import { publishResourceEvent } from '@/server/services/resourceEvents';
|
||||
import { TransferErrorCode } from '@/types/transferError';
|
||||
|
||||
/**
|
||||
@@ -57,7 +55,6 @@ const agentGroupProcedure = wsCompatProcedure.use(serverDatabase).use(async (opt
|
||||
agentGroupService: new AgentGroupService(ctx.serverDB, ctx.userId, wsId),
|
||||
agentModel: new AgentModel(ctx.serverDB, ctx.userId, wsId),
|
||||
chatGroupModel: new ChatGroupModel(ctx.serverDB, ctx.userId, wsId),
|
||||
editLockService: new EditLockService(ctx.userId),
|
||||
userModel: new UserModel(ctx.serverDB, ctx.userId),
|
||||
},
|
||||
});
|
||||
@@ -405,19 +402,6 @@ export const agentGroupRouter = router({
|
||||
}),
|
||||
)
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
// Collaborative edit lock: reject writes to a workspace group another
|
||||
// member is actively editing. Inert until a client acquires the lock.
|
||||
if (ctx.workspaceId) {
|
||||
const blockedBy = await ctx.editLockService.getBlockingHolder('chatGroup', input.id);
|
||||
if (blockedBy) {
|
||||
throw new TRPCError({
|
||||
cause: { data: { code: 'DocumentLocked' } },
|
||||
code: 'CONFLICT',
|
||||
message: 'Group is being edited by another user',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return ctx.chatGroupModel.update(input.id, {
|
||||
...input.value,
|
||||
config: ctx.agentGroupService.normalizeGroupConfig(
|
||||
@@ -425,47 +409,6 @@ export const agentGroupRouter = router({
|
||||
),
|
||||
});
|
||||
}),
|
||||
|
||||
acquireGroupLock: agentGroupProcedureWrite
|
||||
.input(z.object({ id: z.string() }))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
if (!ctx.workspaceId) return { expiresAt: null, holderId: null, lockedByOther: false };
|
||||
const prev = await ctx.editLockService.getActiveHolder('chatGroup', input.id);
|
||||
const result = await ctx.editLockService.acquire('chatGroup', input.id);
|
||||
if ((result.holderId ?? null) !== (prev ?? null)) {
|
||||
void publishResourceEvent(
|
||||
{ id: input.id, type: 'chatGroup' },
|
||||
{ actorId: ctx.userId, data: { holderId: result.holderId }, type: 'lock.changed' },
|
||||
);
|
||||
}
|
||||
return result;
|
||||
}),
|
||||
|
||||
getGroupLock: agentGroupProcedureWrite
|
||||
.input(z.object({ id: z.string() }))
|
||||
.query(async ({ ctx, input }) => {
|
||||
if (!ctx.workspaceId) return { expiresAt: null, holderId: null, lockedByOther: false };
|
||||
const holder = await ctx.editLockService.getActiveHolder('chatGroup', input.id);
|
||||
return {
|
||||
expiresAt: null,
|
||||
holderId: holder ?? null,
|
||||
lockedByOther: Boolean(holder) && holder !== ctx.userId,
|
||||
};
|
||||
}),
|
||||
|
||||
releaseGroupLock: agentGroupProcedureWrite
|
||||
.input(z.object({ id: z.string() }))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
if (!ctx.workspaceId) return;
|
||||
// Only broadcast "unlocked" when we actually released our own lock — if the
|
||||
// lease expired and another member took over, the lock is still held.
|
||||
const released = await ctx.editLockService.release('chatGroup', input.id);
|
||||
if (!released) return;
|
||||
void publishResourceEvent(
|
||||
{ id: input.id, type: 'chatGroup' },
|
||||
{ actorId: ctx.userId, data: { holderId: null }, type: 'lock.changed' },
|
||||
);
|
||||
}),
|
||||
});
|
||||
|
||||
export type AgentGroupRouter = typeof agentGroupRouter;
|
||||
|
||||
@@ -329,9 +329,9 @@ const InterruptTaskSchema = z
|
||||
/** Thread ID */
|
||||
threadId: z.string().optional(),
|
||||
/**
|
||||
* Topic ID — required to cancel remote hetero tasks (openclaw / hermes).
|
||||
* When provided and the topic's runningOperation has a deviceId, the server
|
||||
* will dispatch a cancelHeteroTask tool call to kill the remote process.
|
||||
* Topic ID — required to cancel hetero work that lives outside the server
|
||||
* process. When provided, the topic's runningOperation can route cancellation
|
||||
* to a connected device process or a sandbox background command.
|
||||
*/
|
||||
topicId: z.string().optional(),
|
||||
})
|
||||
|
||||
@@ -163,50 +163,6 @@ export const deviceRouter = router({
|
||||
}),
|
||||
),
|
||||
|
||||
/**
|
||||
* Rename a branch in a directory on a remote device, via the device's
|
||||
* `renameGitBranch` RPC.
|
||||
*/
|
||||
renameGitBranch: deviceProcedure
|
||||
.input(
|
||||
z.object({
|
||||
deviceId: z.string(),
|
||||
from: z.string(),
|
||||
path: z.string(),
|
||||
to: z.string(),
|
||||
}),
|
||||
)
|
||||
.mutation(async ({ ctx, input }) =>
|
||||
deviceGateway.renameGitBranch({
|
||||
deviceId: input.deviceId,
|
||||
from: input.from,
|
||||
path: input.path,
|
||||
to: input.to,
|
||||
userId: ctx.userId,
|
||||
}),
|
||||
),
|
||||
|
||||
/**
|
||||
* Delete a branch in a directory on a remote device, via the device's
|
||||
* `deleteGitBranch` RPC.
|
||||
*/
|
||||
deleteGitBranch: deviceProcedure
|
||||
.input(
|
||||
z.object({
|
||||
branch: z.string(),
|
||||
deviceId: z.string(),
|
||||
path: z.string(),
|
||||
}),
|
||||
)
|
||||
.mutation(async ({ ctx, input }) =>
|
||||
deviceGateway.deleteGitBranch({
|
||||
branch: input.branch,
|
||||
deviceId: input.deviceId,
|
||||
path: input.path,
|
||||
userId: ctx.userId,
|
||||
}),
|
||||
),
|
||||
|
||||
/**
|
||||
* Pull (`--ff-only`) the current branch of a directory on a remote device, via
|
||||
* the device's `pullGitBranch` RPC.
|
||||
|
||||
@@ -253,27 +253,6 @@ export const documentRouter = router({
|
||||
return ctx.documentService.queryDocuments(input);
|
||||
}),
|
||||
|
||||
acquireDocumentLock: documentProcedure
|
||||
.use(withScopedPermission('document:update'))
|
||||
.input(z.object({ id: z.string() }))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
return ctx.documentService.acquireDocumentLock(input.id);
|
||||
}),
|
||||
|
||||
getDocumentLock: documentProcedure
|
||||
.use(withScopedPermission('document:update'))
|
||||
.input(z.object({ id: z.string() }))
|
||||
.query(async ({ ctx, input }) => {
|
||||
return ctx.documentService.getDocumentLock(input.id);
|
||||
}),
|
||||
|
||||
releaseDocumentLock: documentProcedure
|
||||
.use(withScopedPermission('document:update'))
|
||||
.input(z.object({ id: z.string() }))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
await ctx.documentService.releaseDocumentLock(input.id);
|
||||
}),
|
||||
|
||||
updateDocument: documentProcedure
|
||||
.use(withScopedPermission('document:update'))
|
||||
.input(updateDocumentInputSchema)
|
||||
|
||||
@@ -14,8 +14,6 @@ import { TopicModel } from '@/database/models/topic';
|
||||
import { workspaceMembers } from '@/database/schemas';
|
||||
import { router } from '@/libs/trpc/lambda';
|
||||
import { serverDatabase } from '@/libs/trpc/lambda/middleware';
|
||||
import { EditLockService } from '@/server/services/editLock';
|
||||
import { publishResourceEvent } from '@/server/services/resourceEvents';
|
||||
import { TaskService } from '@/server/services/task';
|
||||
import { TaskLifecycleService } from '@/server/services/taskLifecycle';
|
||||
import { TaskRunnerService } from '@/server/services/taskRunner';
|
||||
@@ -28,7 +26,6 @@ const taskProcedure = wsCompatProcedure.use(serverDatabase).use(async (opts) =>
|
||||
ctx: {
|
||||
agentModel: new AgentModel(ctx.serverDB, ctx.userId, wsId),
|
||||
briefModel: new BriefModel(ctx.serverDB, ctx.userId, wsId),
|
||||
editLockService: new EditLockService(ctx.userId),
|
||||
taskLifecycle: new TaskLifecycleService(ctx.serverDB, ctx.userId, wsId),
|
||||
taskModel: new TaskModel(ctx.serverDB, ctx.userId, wsId),
|
||||
taskService: new TaskService(ctx.serverDB, ctx.userId, wsId),
|
||||
@@ -930,20 +927,6 @@ export const taskRouter = router({
|
||||
const model = ctx.taskModel;
|
||||
await assertAssigneeAgentBelongsToUser(ctx.agentModel, data.assigneeAgentId);
|
||||
const resolved = await resolveOrThrow(model, id);
|
||||
|
||||
// Collaborative edit lock: reject writes to a workspace task another member
|
||||
// is actively editing. Inert until a client acquires the lock.
|
||||
if (ctx.workspaceId) {
|
||||
const blockedBy = await ctx.editLockService.getBlockingHolder('task', resolved.id);
|
||||
if (blockedBy) {
|
||||
throw new TRPCError({
|
||||
cause: { data: { code: 'DocumentLocked' } },
|
||||
code: 'CONFLICT',
|
||||
message: 'Task is being edited by another user',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const resolvedParentTaskId =
|
||||
parentTaskId === undefined
|
||||
? undefined
|
||||
@@ -964,44 +947,6 @@ export const taskRouter = router({
|
||||
}
|
||||
}),
|
||||
|
||||
acquireTaskLock: taskProcedureWrite.input(idInput).mutation(async ({ ctx, input }) => {
|
||||
if (!ctx.workspaceId) return { expiresAt: null, holderId: null, lockedByOther: false };
|
||||
const resolved = await resolveOrThrow(ctx.taskModel, input.id);
|
||||
const prev = await ctx.editLockService.getActiveHolder('task', resolved.id);
|
||||
const result = await ctx.editLockService.acquire('task', resolved.id);
|
||||
if ((result.holderId ?? null) !== (prev ?? null)) {
|
||||
void publishResourceEvent(
|
||||
{ id: resolved.id, type: 'task' },
|
||||
{ actorId: ctx.userId, data: { holderId: result.holderId }, type: 'lock.changed' },
|
||||
);
|
||||
}
|
||||
return result;
|
||||
}),
|
||||
|
||||
getTaskLock: taskProcedureWrite.input(idInput).query(async ({ ctx, input }) => {
|
||||
if (!ctx.workspaceId) return { expiresAt: null, holderId: null, lockedByOther: false };
|
||||
const resolved = await resolveOrThrow(ctx.taskModel, input.id);
|
||||
const holder = await ctx.editLockService.getActiveHolder('task', resolved.id);
|
||||
return {
|
||||
expiresAt: null,
|
||||
holderId: holder ?? null,
|
||||
lockedByOther: Boolean(holder) && holder !== ctx.userId,
|
||||
};
|
||||
}),
|
||||
|
||||
releaseTaskLock: taskProcedureWrite.input(idInput).mutation(async ({ ctx, input }) => {
|
||||
if (!ctx.workspaceId) return;
|
||||
const resolved = await resolveOrThrow(ctx.taskModel, input.id);
|
||||
// Only broadcast "unlocked" when we actually released our own lock — if the
|
||||
// lease expired and another member took over, the lock is still held.
|
||||
const released = await ctx.editLockService.release('task', resolved.id);
|
||||
if (!released) return;
|
||||
void publishResourceEvent(
|
||||
{ id: resolved.id, type: 'task' },
|
||||
{ actorId: ctx.userId, data: { holderId: null }, type: 'lock.changed' },
|
||||
);
|
||||
}),
|
||||
|
||||
updateConfig: taskProcedureWrite
|
||||
.input(idInput.merge(z.object({ config: z.record(z.unknown()) })))
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
|
||||
@@ -162,18 +162,6 @@ export const topicRouter = router({
|
||||
return ctx.topicModel.batchDeleteBySessionId(resolved.sessionId);
|
||||
}),
|
||||
|
||||
batchMoveTopics: topicProcedure
|
||||
.use(withScopedPermission('topic:update'))
|
||||
.input(
|
||||
z.object({
|
||||
targetAgentId: z.string(),
|
||||
topicIds: z.array(z.string()),
|
||||
}),
|
||||
)
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
return ctx.topicModel.batchMoveToAgent(input.topicIds, input.targetAgentId);
|
||||
}),
|
||||
|
||||
cloneTopic: topicProcedure
|
||||
.use(withScopedPermission('topic:create'))
|
||||
.input(z.object({ id: z.string(), newTitle: z.string().optional() }))
|
||||
@@ -697,6 +685,7 @@ export const topicRouter = router({
|
||||
runningOperation: z
|
||||
.object({
|
||||
assistantMessageId: z.string(),
|
||||
cancelRequestedAt: z.string().optional(),
|
||||
completionWebhook: z
|
||||
.object({
|
||||
body: z.record(z.unknown()).optional(),
|
||||
@@ -704,7 +693,10 @@ export const topicRouter = router({
|
||||
url: z.string(),
|
||||
})
|
||||
.optional(),
|
||||
deviceId: z.string().optional(),
|
||||
heteroType: z.string().optional(),
|
||||
operationId: z.string(),
|
||||
sandboxCommandId: z.string().optional(),
|
||||
scope: z.string().optional(),
|
||||
threadId: z.string().nullable().optional(),
|
||||
})
|
||||
|
||||
@@ -2,13 +2,17 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { AiAgentService } from '../index';
|
||||
|
||||
const { mockMessageCreate, mockResolveAttachmentMetadata, mockSpawnHeteroSandbox } = vi.hoisted(
|
||||
() => ({
|
||||
mockMessageCreate: vi.fn(),
|
||||
mockResolveAttachmentMetadata: vi.fn(),
|
||||
mockSpawnHeteroSandbox: vi.fn().mockResolvedValue(undefined),
|
||||
}),
|
||||
);
|
||||
const {
|
||||
mockMessageCreate,
|
||||
mockResolveAttachmentMetadata,
|
||||
mockSandboxCallTool,
|
||||
mockSpawnHeteroSandbox,
|
||||
} = vi.hoisted(() => ({
|
||||
mockMessageCreate: vi.fn(),
|
||||
mockResolveAttachmentMetadata: vi.fn(),
|
||||
mockSandboxCallTool: vi.fn(),
|
||||
mockSpawnHeteroSandbox: vi.fn().mockResolvedValue({}),
|
||||
}));
|
||||
|
||||
vi.mock('@/libs/trusted-client', () => ({
|
||||
generateTrustedClientToken: vi.fn().mockReturnValue(undefined),
|
||||
@@ -99,6 +103,12 @@ vi.mock('@/server/services/heterogeneousAgent/sandboxRunner', () => ({
|
||||
spawnHeteroSandbox: mockSpawnHeteroSandbox,
|
||||
}));
|
||||
|
||||
vi.mock('@/server/services/sandbox', () => ({
|
||||
createSandboxService: vi.fn(() => ({
|
||||
callTool: mockSandboxCallTool,
|
||||
})),
|
||||
}));
|
||||
|
||||
vi.mock('@/server/services/file/resolveAttachments', () => ({
|
||||
resolveAttachmentMetadata: mockResolveAttachmentMetadata,
|
||||
resolveAttachmentsByFileIds: vi.fn().mockResolvedValue({
|
||||
@@ -148,7 +158,8 @@ describe('AiAgentService.execAgent - hetero early-exit file attachments', () =>
|
||||
topicMock.updateMetadata.mockResolvedValue(undefined);
|
||||
mockMessageCreate.mockResolvedValue({ id: 'msg-1' });
|
||||
mockResolveAttachmentMetadata.mockResolvedValue([]);
|
||||
mockSpawnHeteroSandbox.mockResolvedValue(undefined);
|
||||
mockSandboxCallTool.mockResolvedValue({ success: true });
|
||||
mockSpawnHeteroSandbox.mockResolvedValue({});
|
||||
|
||||
service = new AiAgentService(mockDb, userId);
|
||||
});
|
||||
@@ -290,4 +301,48 @@ describe('AiAgentService.execAgent - hetero early-exit file attachments', () =>
|
||||
expect(mockResolveAttachmentMetadata).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('sandbox stop race', () => {
|
||||
it('should kill the sandbox command when stop was requested before commandId is persisted', async () => {
|
||||
mockSpawnHeteroSandbox.mockResolvedValue({ commandId: 'cmd-delayed' });
|
||||
topicMock.findById.mockImplementation(async () => {
|
||||
const seededRunningOperation = topicMock.updateMetadata.mock.calls.find(
|
||||
([, metadata]) => metadata.runningOperation?.operationId,
|
||||
)?.[1].runningOperation;
|
||||
|
||||
return {
|
||||
id: 'topic-1',
|
||||
metadata: {
|
||||
runningOperation: seededRunningOperation
|
||||
? {
|
||||
...seededRunningOperation,
|
||||
cancelRequestedAt: '2026-01-01T00:00:00.000Z',
|
||||
}
|
||||
: undefined,
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
await service.execAgent({
|
||||
agentId: 'agent-1',
|
||||
prompt: 'Run in sandbox',
|
||||
});
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(mockSandboxCallTool).toHaveBeenCalledWith('killCommand', {
|
||||
commandId: 'cmd-delayed',
|
||||
});
|
||||
});
|
||||
|
||||
expect(topicMock.updateMetadata).toHaveBeenCalledWith(
|
||||
'topic-1',
|
||||
expect.objectContaining({
|
||||
runningOperation: expect.objectContaining({
|
||||
cancelRequestedAt: '2026-01-01T00:00:00.000Z',
|
||||
sandboxCommandId: 'cmd-delayed',
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -29,6 +29,7 @@ import { buildTaskManagerDefaultsPrompt } from '@lobechat/prompts';
|
||||
import type {
|
||||
ChatFileItem,
|
||||
ChatTopicBotContext,
|
||||
ChatTopicMetadata,
|
||||
ChatVideoItem,
|
||||
ExecAgentParams,
|
||||
ExecAgentResult,
|
||||
@@ -106,6 +107,7 @@ import { HeterogeneousAgentService } from '@/server/services/heterogeneousAgent'
|
||||
import type { ConversationHistoryEntry } from '@/server/services/heterogeneousAgent/cloudHeteroContext';
|
||||
import { KlavisService } from '@/server/services/klavis';
|
||||
import { MarketService } from '@/server/services/market';
|
||||
import { createSandboxService } from '@/server/services/sandbox';
|
||||
import { markdownToTxt } from '@/utils/markdownToTxt';
|
||||
|
||||
import { resolveDeviceAccessPolicy } from './deviceAccessPolicy';
|
||||
@@ -1040,22 +1042,49 @@ export class AiAgentService {
|
||||
|
||||
const remoteDeviceId =
|
||||
requestedDeviceId || agentConfig.agencyConfig?.boundDeviceId || undefined;
|
||||
type RunningOperationMetadata = NonNullable<ChatTopicMetadata['runningOperation']>;
|
||||
const buildRunningOperationMetadata = (
|
||||
extra: Partial<RunningOperationMetadata> = {},
|
||||
): RunningOperationMetadata => ({
|
||||
assistantMessageId: assistantMsg.id,
|
||||
completionWebhook: hooks?.find((h) => h.type === 'onComplete')?.webhook,
|
||||
heteroType,
|
||||
operationId,
|
||||
scope: appContext?.scope ?? undefined,
|
||||
threadId: appContext?.threadId ?? undefined,
|
||||
...extra,
|
||||
});
|
||||
const updateRunningOperationMetadata = async (
|
||||
extra: Partial<RunningOperationMetadata>,
|
||||
): Promise<RunningOperationMetadata | undefined> => {
|
||||
const latestTopic = await this.topicModel.findById(topicId);
|
||||
const current = latestTopic?.metadata?.runningOperation;
|
||||
if (current && current.operationId !== operationId) {
|
||||
log(
|
||||
'execAgent: skip runningOperation update for stale op=%s current=%s',
|
||||
operationId,
|
||||
current.operationId,
|
||||
);
|
||||
return;
|
||||
}
|
||||
const runningOperation = {
|
||||
...buildRunningOperationMetadata(),
|
||||
...current,
|
||||
...extra,
|
||||
};
|
||||
await this.topicModel.updateMetadata(topicId, {
|
||||
runningOperation,
|
||||
});
|
||||
return runningOperation;
|
||||
};
|
||||
|
||||
// Seed topic.metadata.runningOperation so heteroIngest can validate the operation.
|
||||
// completionWebhook is stored so heteroFinish can call back to the IM bot-callback
|
||||
// endpoint even though the hetero path bypasses the normal hook registration flow.
|
||||
await this.topicModel.updateMetadata(topicId, {
|
||||
runningOperation: {
|
||||
assistantMessageId: assistantMsg.id,
|
||||
completionWebhook: hooks?.find((h) => h.type === 'onComplete')?.webhook,
|
||||
// Store deviceId + heteroType so interruptTask can cancel remote processes
|
||||
...(isRemoteHetero && remoteDeviceId
|
||||
? { deviceId: remoteDeviceId, heteroType }
|
||||
: undefined),
|
||||
operationId,
|
||||
scope: appContext?.scope ?? undefined,
|
||||
threadId: appContext?.threadId ?? undefined,
|
||||
},
|
||||
runningOperation: buildRunningOperationMetadata(
|
||||
isRemoteHetero && remoteDeviceId ? { deviceId: remoteDeviceId } : {},
|
||||
),
|
||||
});
|
||||
|
||||
// Remote hetero agents (openclaw / hermes) dispatch to the device identified
|
||||
@@ -1241,6 +1270,8 @@ export class AiAgentService {
|
||||
userMessageId: userMsg?.id ?? parentMessageId ?? '',
|
||||
};
|
||||
}
|
||||
await updateRunningOperationMetadata({ deviceId: dispatchDeviceId });
|
||||
|
||||
// Resolve the working directory for the run: a topic-level override
|
||||
// wins, else the device's user-configured defaultCwd. The device row
|
||||
// lives in the DB (the gateway only knows live connections), so read
|
||||
@@ -1318,9 +1349,24 @@ export class AiAgentService {
|
||||
...heteroParams,
|
||||
agentType: heteroType as 'claude-code' | 'codex',
|
||||
marketService: this.marketService,
|
||||
}).catch((err) => {
|
||||
log('execAgent: hetero sandbox spawn failed: %O', err);
|
||||
});
|
||||
})
|
||||
.then(async ({ commandId }) => {
|
||||
if (!commandId) return;
|
||||
const runningOperation = await updateRunningOperationMetadata({
|
||||
sandboxCommandId: commandId,
|
||||
});
|
||||
if (!runningOperation?.cancelRequestedAt) return;
|
||||
await createSandboxService({
|
||||
marketService: this.marketService,
|
||||
topicId,
|
||||
userId: this.userId,
|
||||
})
|
||||
.callTool('killCommand', { commandId })
|
||||
.catch((err) => log('execAgent: delayed sandbox killCommand failed: %O', err));
|
||||
})
|
||||
.catch((err) => {
|
||||
log('execAgent: hetero sandbox spawn failed: %O', err);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3410,31 +3456,50 @@ export class AiAgentService {
|
||||
throw new Error('Operation ID not found');
|
||||
}
|
||||
|
||||
// 2. Cancel remote hetero process (openclaw / hermes) if applicable.
|
||||
// Check topic.metadata.runningOperation for device + heteroType info seeded by execAgent.
|
||||
// 2. Cancel hetero processes when the run lives outside the server process.
|
||||
// Device-dispatched local CLI agents (claude-code / codex) and remote
|
||||
// platform agents (openclaw / hermes) are killed through the connected
|
||||
// device. Sandbox-dispatched local CLI agents are killed through sandbox
|
||||
// command cancellation when the background command id is available.
|
||||
// This runs regardless of whether interruptOperation succeeds — the remote process
|
||||
// is independent of the local operation registry.
|
||||
if (topicId) {
|
||||
const topic = await this.topicModel.findById(topicId);
|
||||
const runningOp = (topic?.metadata as any)?.runningOperation as
|
||||
| { deviceId?: string; heteroType?: string; operationId?: string }
|
||||
| undefined;
|
||||
const runningOp = topic?.metadata?.runningOperation;
|
||||
|
||||
if (
|
||||
runningOp?.deviceId &&
|
||||
runningOp.heteroType &&
|
||||
isRemoteHeterogeneousType(runningOp.heteroType)
|
||||
) {
|
||||
const taskId = runningOp.operationId ?? resolvedOperationId;
|
||||
const runningOperation =
|
||||
runningOp?.operationId === resolvedOperationId
|
||||
? {
|
||||
...runningOp,
|
||||
operationId: resolvedOperationId,
|
||||
}
|
||||
: undefined;
|
||||
|
||||
if (runningOp && runningOp.operationId !== resolvedOperationId) {
|
||||
log(
|
||||
'interruptTask: cancelling remote hetero process heteroType=%s deviceId=%s taskId=%s',
|
||||
runningOp.heteroType,
|
||||
runningOp.deviceId,
|
||||
'interruptTask: skip hetero process cancel for stale op=%s current=%s topicId=%s',
|
||||
resolvedOperationId,
|
||||
runningOp.operationId,
|
||||
topicId,
|
||||
);
|
||||
} else if (runningOperation) {
|
||||
const cancelRequestedAt = runningOperation.cancelRequestedAt ?? new Date().toISOString();
|
||||
await this.topicModel.updateMetadata(topicId, {
|
||||
runningOperation: { ...runningOperation, cancelRequestedAt },
|
||||
});
|
||||
}
|
||||
|
||||
if (runningOperation?.deviceId && runningOperation.heteroType) {
|
||||
const taskId = runningOperation.operationId;
|
||||
log(
|
||||
'interruptTask: cancelling hetero device process heteroType=%s deviceId=%s taskId=%s',
|
||||
runningOperation.heteroType,
|
||||
runningOperation.deviceId,
|
||||
taskId,
|
||||
);
|
||||
await deviceGateway
|
||||
.executeToolCall(
|
||||
{ deviceId: runningOp.deviceId, userId: this.userId },
|
||||
{ deviceId: runningOperation.deviceId, userId: this.userId },
|
||||
{
|
||||
apiName: 'cancelHeteroTask',
|
||||
arguments: JSON.stringify({ signal: 'SIGINT', taskId }),
|
||||
@@ -3444,6 +3509,21 @@ export class AiAgentService {
|
||||
)
|
||||
.catch((err) => log('interruptTask: cancelHeteroTask dispatch failed: %O', err));
|
||||
}
|
||||
|
||||
if (runningOperation?.sandboxCommandId) {
|
||||
log(
|
||||
'interruptTask: cancelling hetero sandbox command commandId=%s topicId=%s',
|
||||
runningOperation.sandboxCommandId,
|
||||
topicId,
|
||||
);
|
||||
await createSandboxService({
|
||||
marketService: this.marketService,
|
||||
topicId,
|
||||
userId: this.userId,
|
||||
})
|
||||
.callTool('killCommand', { commandId: runningOperation.sandboxCommandId })
|
||||
.catch((err) => log('interruptTask: sandbox killCommand failed: %O', err));
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Interrupt the runtime operation first. Only mark the thread cancelled
|
||||
|
||||
@@ -14,11 +14,9 @@ import type {
|
||||
DeviceGitBranchInfo,
|
||||
DeviceGitBranchListItem,
|
||||
DeviceGitCheckoutResult,
|
||||
DeviceGitDeleteBranchResult,
|
||||
DeviceGitFileRevertResult,
|
||||
DeviceGitLinkedPullRequestResult,
|
||||
DeviceGitRemoteBranchListItem,
|
||||
DeviceGitRenameBranchResult,
|
||||
DeviceGitSyncResult,
|
||||
DeviceGitWorkingTreeFiles,
|
||||
DeviceGitWorkingTreePatches,
|
||||
@@ -274,73 +272,6 @@ export class DeviceGateway {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Rename a branch in a directory on a remote device via the `renameGitBranch`
|
||||
* device RPC.
|
||||
*/
|
||||
async renameGitBranch(params: {
|
||||
deviceId: string;
|
||||
from: string;
|
||||
path: string;
|
||||
timeout?: number;
|
||||
to: string;
|
||||
userId: string;
|
||||
}): Promise<DeviceGitRenameBranchResult> {
|
||||
const { userId, deviceId, from, to, path, timeout = 30_000 } = params;
|
||||
const client = this.getClient();
|
||||
if (!client) return { error: 'Device gateway not configured', success: false };
|
||||
|
||||
try {
|
||||
const result = await client.invokeRpc<DeviceGitRenameBranchResult>(
|
||||
{ deviceId, timeout, userId },
|
||||
{ method: 'renameGitBranch', params: { from, path, to } },
|
||||
);
|
||||
|
||||
if (!result.success || !result.data) {
|
||||
log('renameGitBranch: failed for deviceId=%s — %s', deviceId, result.error);
|
||||
return { error: result.error || 'Rename failed', success: false };
|
||||
}
|
||||
|
||||
return result.data;
|
||||
} catch (error) {
|
||||
log('renameGitBranch: error for deviceId=%s — %O', deviceId, error);
|
||||
return { error: (error as Error)?.message || 'Rename failed', success: false };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a branch in a directory on a remote device via the `deleteGitBranch`
|
||||
* device RPC.
|
||||
*/
|
||||
async deleteGitBranch(params: {
|
||||
branch: string;
|
||||
deviceId: string;
|
||||
path: string;
|
||||
timeout?: number;
|
||||
userId: string;
|
||||
}): Promise<DeviceGitDeleteBranchResult> {
|
||||
const { userId, deviceId, branch, path, timeout = 30_000 } = params;
|
||||
const client = this.getClient();
|
||||
if (!client) return { error: 'Device gateway not configured', success: false };
|
||||
|
||||
try {
|
||||
const result = await client.invokeRpc<DeviceGitDeleteBranchResult>(
|
||||
{ deviceId, timeout, userId },
|
||||
{ method: 'deleteGitBranch', params: { branch, path } },
|
||||
);
|
||||
|
||||
if (!result.success || !result.data) {
|
||||
log('deleteGitBranch: failed for deviceId=%s — %s', deviceId, result.error);
|
||||
return { error: result.error || 'Delete failed', success: false };
|
||||
}
|
||||
|
||||
return result.data;
|
||||
} catch (error) {
|
||||
log('deleteGitBranch: error for deviceId=%s — %O', deviceId, error);
|
||||
return { error: (error as Error)?.message || 'Delete failed', success: false };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Pull (`--ff-only`) the current branch of a directory on a remote device via
|
||||
* the `pullGitBranch` device RPC.
|
||||
|
||||
@@ -5,22 +5,14 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { DocumentModel } from '@/database/models/document';
|
||||
import { FileModel } from '@/database/models/file';
|
||||
|
||||
import { EditLockService } from '../../editLock';
|
||||
import { FileService } from '../../file';
|
||||
import { publishResourceEvent } from '../../resourceEvents';
|
||||
import { DocumentHistoryService } from '../history';
|
||||
import { DocumentService } from '../index';
|
||||
|
||||
vi.mock('@/server/modules/AgentRuntime/redis', () => ({ getAgentRuntimeRedisClient: () => null }));
|
||||
vi.mock('@/database/models/document');
|
||||
vi.mock('@/database/models/file');
|
||||
vi.mock('../../file');
|
||||
vi.mock('../history');
|
||||
// Spy on the realtime broadcast so we can assert lock.changed is published only
|
||||
// on a genuine state change (holder edge / actual release).
|
||||
vi.mock('../../resourceEvents', () => ({ publishResourceEvent: vi.fn() }));
|
||||
|
||||
const publishResourceEventMock = vi.mocked(publishResourceEvent);
|
||||
vi.mock('@lobechat/file-loaders', () => ({
|
||||
loadFile: vi.fn(),
|
||||
UnsupportedFileTypeError: class UnsupportedFileTypeError extends Error {
|
||||
@@ -802,168 +794,6 @@ describe('DocumentService', () => {
|
||||
'Document not found: missing-doc',
|
||||
);
|
||||
});
|
||||
|
||||
it('should reject a workspace save when another member holds the edit lock', async () => {
|
||||
const wsService = new DocumentService(mockDb, userId, 'ws-1');
|
||||
mockDocumentModel.findById.mockResolvedValue(createCurrentDocument({ workspaceId: 'ws-1' }));
|
||||
vi.spyOn(EditLockService.prototype, 'getBlockingHolder').mockResolvedValue('other-user');
|
||||
|
||||
await expect(wsService.updateDocument('doc-1', { content: 'x' })).rejects.toMatchObject({
|
||||
code: 'CONFLICT',
|
||||
});
|
||||
expect(mockDocumentModel.update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should allow a workspace save when no other member holds the lock', async () => {
|
||||
const wsService = new DocumentService(mockDb, userId, 'ws-1');
|
||||
mockDocumentModel.update.mockResolvedValue({ id: 'doc-1' });
|
||||
mockDocumentModel.findById.mockResolvedValue(createCurrentDocument({ workspaceId: 'ws-1' }));
|
||||
vi.spyOn(EditLockService.prototype, 'getBlockingHolder').mockResolvedValue(null);
|
||||
|
||||
await wsService.updateDocument('doc-1', { content: 'x' });
|
||||
|
||||
expect(mockDocumentModel.update).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('allows a metadata-only save while another member holds the lock (only the body is locked)', async () => {
|
||||
const wsService = new DocumentService(mockDb, userId, 'ws-1');
|
||||
mockDocumentModel.update.mockResolvedValue({ id: 'doc-1' });
|
||||
// Current body matches what the autosave re-sends — only title changes.
|
||||
mockDocumentModel.findById.mockResolvedValue(
|
||||
createCurrentDocument({ content: 'body', editorData: { blocks: [] }, workspaceId: 'ws-1' }),
|
||||
);
|
||||
const guardSpy = vi.spyOn(EditLockService.prototype, 'getBlockingHolder');
|
||||
|
||||
await wsService.updateDocument('doc-1', {
|
||||
content: 'body',
|
||||
editorData: { blocks: [] },
|
||||
title: 'New Title',
|
||||
});
|
||||
|
||||
// Content unchanged → the lock guard never runs and the meta save lands.
|
||||
expect(guardSpy).not.toHaveBeenCalled();
|
||||
expect(mockDocumentModel.update).toHaveBeenCalledWith(
|
||||
'doc-1',
|
||||
expect.objectContaining({ title: 'New Title' }),
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects a body change while locked even when the content string is unchanged', async () => {
|
||||
const wsService = new DocumentService(mockDb, userId, 'ws-1');
|
||||
mockDocumentModel.findById.mockResolvedValue(
|
||||
createCurrentDocument({ editorData: { blocks: [] }, workspaceId: 'ws-1' }),
|
||||
);
|
||||
vi.spyOn(EditLockService.prototype, 'getBlockingHolder').mockResolvedValue('other-user');
|
||||
|
||||
// editorData changed (historyAppended) → guard runs even with no `content`.
|
||||
await expect(
|
||||
wsService.updateDocument('doc-1', { editorData: { blocks: [{ type: 'paragraph' }] } }),
|
||||
).rejects.toMatchObject({ code: 'CONFLICT' });
|
||||
expect(mockDocumentModel.update).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('document edit lock', () => {
|
||||
it('reports unlocked for personal documents without touching the lock service', async () => {
|
||||
const acquireSpy = vi.spyOn(EditLockService.prototype, 'acquire');
|
||||
|
||||
const result = await service.acquireDocumentLock('doc-1');
|
||||
|
||||
expect(result).toEqual({ expiresAt: null, holderId: null, lockedByOther: false });
|
||||
expect(acquireSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('delegates to the edit lock service in workspace mode', async () => {
|
||||
const wsService = new DocumentService(mockDb, userId, 'ws-1');
|
||||
const expiresAt = new Date(Date.now() + 60_000);
|
||||
const acquireSpy = vi
|
||||
.spyOn(EditLockService.prototype, 'acquire')
|
||||
.mockResolvedValue({ expiresAt, holderId: userId, lockedByOther: false });
|
||||
|
||||
const result = await wsService.acquireDocumentLock('doc-1');
|
||||
|
||||
expect(acquireSpy).toHaveBeenCalledWith('document', 'doc-1');
|
||||
expect(result).toEqual({ expiresAt, holderId: userId, lockedByOther: false });
|
||||
});
|
||||
|
||||
it('reports another member as holder when the lock is taken', async () => {
|
||||
const wsService = new DocumentService(mockDb, userId, 'ws-1');
|
||||
const expiresAt = new Date(Date.now() + 60_000);
|
||||
vi.spyOn(EditLockService.prototype, 'acquire').mockResolvedValue({
|
||||
expiresAt,
|
||||
holderId: 'other-user',
|
||||
lockedByOther: true,
|
||||
});
|
||||
|
||||
const result = await wsService.acquireDocumentLock('doc-1');
|
||||
|
||||
expect(result).toEqual({ expiresAt, holderId: 'other-user', lockedByOther: true });
|
||||
});
|
||||
|
||||
it('releaseDocumentLock is a no-op for personal documents', async () => {
|
||||
const releaseSpy = vi.spyOn(EditLockService.prototype, 'release');
|
||||
await service.releaseDocumentLock('doc-1');
|
||||
expect(releaseSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('releaseDocumentLock delegates to the lock service in workspace mode', async () => {
|
||||
const wsService = new DocumentService(mockDb, userId, 'ws-1');
|
||||
const releaseSpy = vi.spyOn(EditLockService.prototype, 'release').mockResolvedValue(true);
|
||||
await wsService.releaseDocumentLock('doc-1');
|
||||
expect(releaseSpy).toHaveBeenCalledWith('document', 'doc-1');
|
||||
});
|
||||
|
||||
it('acquireDocumentLock broadcasts lock.changed on a holder edge (first claim)', async () => {
|
||||
const wsService = new DocumentService(mockDb, userId, 'ws-1');
|
||||
vi.spyOn(EditLockService.prototype, 'getActiveHolder').mockResolvedValue(undefined);
|
||||
vi.spyOn(EditLockService.prototype, 'acquire').mockResolvedValue({
|
||||
expiresAt: new Date(),
|
||||
holderId: userId,
|
||||
lockedByOther: false,
|
||||
});
|
||||
|
||||
await wsService.acquireDocumentLock('doc-1');
|
||||
|
||||
expect(publishResourceEventMock).toHaveBeenCalledWith(
|
||||
{ id: 'doc-1', type: 'document' },
|
||||
expect.objectContaining({ data: { holderId: userId }, type: 'lock.changed' }),
|
||||
);
|
||||
});
|
||||
|
||||
it('acquireDocumentLock does NOT broadcast on a steady-state heartbeat (same holder)', async () => {
|
||||
const wsService = new DocumentService(mockDb, userId, 'ws-1');
|
||||
vi.spyOn(EditLockService.prototype, 'getActiveHolder').mockResolvedValue(userId);
|
||||
vi.spyOn(EditLockService.prototype, 'acquire').mockResolvedValue({
|
||||
expiresAt: new Date(),
|
||||
holderId: userId,
|
||||
lockedByOther: false,
|
||||
});
|
||||
|
||||
await wsService.acquireDocumentLock('doc-1');
|
||||
|
||||
expect(publishResourceEventMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('releaseDocumentLock broadcasts unlocked only when it actually freed the lock', async () => {
|
||||
const wsService = new DocumentService(mockDb, userId, 'ws-1');
|
||||
vi.spyOn(EditLockService.prototype, 'release').mockResolvedValue(true);
|
||||
|
||||
await wsService.releaseDocumentLock('doc-1');
|
||||
|
||||
expect(publishResourceEventMock).toHaveBeenCalledWith(
|
||||
{ id: 'doc-1', type: 'document' },
|
||||
expect.objectContaining({ data: { holderId: null }, type: 'lock.changed' }),
|
||||
);
|
||||
});
|
||||
|
||||
it('releaseDocumentLock does NOT broadcast when the lease expired / was taken over', async () => {
|
||||
const wsService = new DocumentService(mockDb, userId, 'ws-1');
|
||||
vi.spyOn(EditLockService.prototype, 'release').mockResolvedValue(false);
|
||||
|
||||
await wsService.releaseDocumentLock('doc-1');
|
||||
|
||||
expect(publishResourceEventMock).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('saveDocumentHistory', () => {
|
||||
@@ -1007,37 +837,6 @@ describe('DocumentService', () => {
|
||||
);
|
||||
expect(mockDocumentHistoryService.createHistory).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not check the lock for personal documents', async () => {
|
||||
mockDocumentModel.findById.mockResolvedValue({ id: 'doc-1', editorData: { blocks: [] } });
|
||||
const guardSpy = vi.spyOn(EditLockService.prototype, 'getBlockingHolder');
|
||||
|
||||
await service.saveDocumentHistory('doc-1', { blocks: [] }, 'llm_call');
|
||||
|
||||
expect(guardSpy).not.toHaveBeenCalled();
|
||||
expect(mockDocumentHistoryService.createHistory).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rejects a workspace history snapshot when another member holds the lock', async () => {
|
||||
const wsService = new DocumentService(mockDb, userId, 'ws-1');
|
||||
mockDocumentModel.findById.mockResolvedValue({ id: 'doc-1', editorData: { blocks: [] } });
|
||||
vi.spyOn(EditLockService.prototype, 'getBlockingHolder').mockResolvedValue('other-user');
|
||||
|
||||
await expect(
|
||||
wsService.saveDocumentHistory('doc-1', { blocks: [] }, 'llm_call'),
|
||||
).rejects.toMatchObject({ code: 'CONFLICT' });
|
||||
expect(mockDocumentHistoryService.createHistory).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('allows a workspace history snapshot when no other member holds the lock', async () => {
|
||||
const wsService = new DocumentService(mockDb, userId, 'ws-1');
|
||||
mockDocumentModel.findById.mockResolvedValue({ id: 'doc-1', editorData: { blocks: [] } });
|
||||
vi.spyOn(EditLockService.prototype, 'getBlockingHolder').mockResolvedValue(null);
|
||||
|
||||
await wsService.saveDocumentHistory('doc-1', { blocks: [] }, 'llm_call');
|
||||
|
||||
expect(mockDocumentHistoryService.createHistory).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('trySaveCurrentDocumentHistory', () => {
|
||||
|
||||
@@ -15,16 +15,13 @@ import { isValidEditorData } from '@/libs/editor/isValidEditorData';
|
||||
import { normalizeEditorDataDiffNodes } from '@/libs/editor/normalizeDiffNodes';
|
||||
import { type LobeDocument } from '@/types/document';
|
||||
|
||||
import { EditLockService } from '../editLock';
|
||||
import { FileService } from '../file';
|
||||
import { publishResourceEvent } from '../resourceEvents';
|
||||
import { DocumentHistoryService } from './history';
|
||||
import type {
|
||||
CompareDocumentHistoryItemsParams,
|
||||
CompareDocumentHistoryItemsResult,
|
||||
DocumentHistoryAccessOptions,
|
||||
DocumentHistorySaveSource,
|
||||
DocumentLockResult,
|
||||
GetDocumentHistoryItemParams,
|
||||
ListDocumentHistoryParams,
|
||||
ListDocumentHistoryResult,
|
||||
@@ -53,7 +50,6 @@ export class DocumentService {
|
||||
private documentModel: DocumentModel;
|
||||
private documentHistoryServiceInstance?: DocumentHistoryService;
|
||||
private fileServiceInstance?: FileService;
|
||||
private editLockService: EditLockService;
|
||||
private db: LobeChatDatabase;
|
||||
|
||||
private workspaceId?: string;
|
||||
@@ -64,7 +60,6 @@ export class DocumentService {
|
||||
this.workspaceId = workspaceId;
|
||||
this.fileModel = new FileModel(db, userId, workspaceId);
|
||||
this.documentModel = new DocumentModel(db, userId, workspaceId);
|
||||
this.editLockService = new EditLockService(userId);
|
||||
}
|
||||
|
||||
private get fileService() {
|
||||
@@ -207,63 +202,6 @@ export class DocumentService {
|
||||
return this.documentModel.findById(id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Acquire (or refresh) the collaborative edit lock for a workspace document.
|
||||
*
|
||||
* Doubles as the heartbeat: an active editor calls this on an interval to keep
|
||||
* the lease alive, and a locked-out member calls it to take the lock over once
|
||||
* it frees up. Locking only applies in workspace context — personal documents
|
||||
* always report as unlocked.
|
||||
*/
|
||||
async acquireDocumentLock(id: string): Promise<DocumentLockResult> {
|
||||
if (!this.workspaceId) return { expiresAt: null, holderId: null, lockedByOther: false };
|
||||
|
||||
const prevHolder = await this.editLockService.getActiveHolder('document', id);
|
||||
const result = await this.editLockService.acquire('document', id);
|
||||
|
||||
// Broadcast only on a holder edge (first claim / takeover). This method also
|
||||
// serves the periodic heartbeat, so a steady-state refresh (same holder)
|
||||
// must not emit an event.
|
||||
if ((result.holderId ?? null) !== (prevHolder ?? null)) {
|
||||
void publishResourceEvent(
|
||||
{ id, type: 'document' },
|
||||
{ actorId: this.userId, data: { holderId: result.holderId }, type: 'lock.changed' },
|
||||
);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Read-only peek of the current edit lock (does not acquire). Lets a client
|
||||
* render a workspace page read-only on open when another member holds it.
|
||||
*/
|
||||
async getDocumentLock(id: string): Promise<DocumentLockResult> {
|
||||
if (!this.workspaceId) return { expiresAt: null, holderId: null, lockedByOther: false };
|
||||
const holder = await this.editLockService.getActiveHolder('document', id);
|
||||
return {
|
||||
expiresAt: null,
|
||||
holderId: holder ?? null,
|
||||
lockedByOther: Boolean(holder) && holder !== this.userId,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Release the edit lock if the current user holds it. No-op in personal mode.
|
||||
*/
|
||||
async releaseDocumentLock(id: string): Promise<void> {
|
||||
if (!this.workspaceId) return;
|
||||
// Only broadcast "unlocked" when we actually released our own lock — if the
|
||||
// lease had expired and another member took over, the lock is still held and
|
||||
// a bogus holderId:null would wrongly flip their viewers to editable.
|
||||
const released = await this.editLockService.release('document', id);
|
||||
if (!released) return;
|
||||
void publishResourceEvent(
|
||||
{ id, type: 'document' },
|
||||
{ actorId: this.userId, data: { holderId: null }, type: 'lock.changed' },
|
||||
);
|
||||
}
|
||||
|
||||
async listDocumentHistory(
|
||||
params: ListDocumentHistoryParams,
|
||||
options?: DocumentHistoryAccessOptions,
|
||||
@@ -298,21 +236,6 @@ export class DocumentService {
|
||||
throw new Error(`Document not found: ${documentId}`);
|
||||
}
|
||||
|
||||
// Same collaborative edit-lock guard as updateDocument: don't record a
|
||||
// history snapshot for a workspace document another member is editing, so a
|
||||
// locked-out actor (e.g. a Copilot mutation that will itself be rejected)
|
||||
// can't pollute the version timeline.
|
||||
if (this.workspaceId) {
|
||||
const blockedBy = await this.editLockService.getBlockingHolder('document', documentId);
|
||||
if (blockedBy) {
|
||||
throw new TRPCError({
|
||||
cause: { data: { code: 'DocumentLocked' } },
|
||||
code: 'CONFLICT',
|
||||
message: 'Document is being edited by another user',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const normalizedEditorData = normalizeEditorDataDiffNodes(editorData);
|
||||
const savedAt = new Date();
|
||||
await this.documentHistoryService.createHistory({
|
||||
@@ -408,8 +331,7 @@ export class DocumentService {
|
||||
* Update document
|
||||
*/
|
||||
async updateDocument(id: string, params: UpdateDocumentParams): Promise<UpdateDocumentResult> {
|
||||
let changed = false;
|
||||
const result = await this.db.transaction(async (tx) => {
|
||||
return this.db.transaction(async (tx) => {
|
||||
const transactionDb = tx as unknown as LobeChatDatabase;
|
||||
const documentModel = new DocumentModel(transactionDb, this.userId, this.workspaceId);
|
||||
const fileModel = new FileModel(transactionDb, this.userId, this.workspaceId);
|
||||
@@ -439,26 +361,6 @@ export class DocumentService {
|
||||
nextEditorDataAccepted !== undefined &&
|
||||
!isEqual(nextEditorDataAccepted, currentEditorDataAccepted);
|
||||
|
||||
// Collaborative edit lock guard: reject writes to a workspace document that
|
||||
// another member is actively editing, so concurrent edits can't clobber
|
||||
// each other. Only the rich-text BODY is locked — metadata-only saves
|
||||
// (title/emoji) pass through, since the autosave always re-sends the
|
||||
// unchanged body. The lease auto-expires in Redis; when Redis is down this
|
||||
// returns null (fail-open) so the lock can't block saving.
|
||||
const contentChanged =
|
||||
historyAppended ||
|
||||
(params.content !== undefined && params.content !== currentDocument.content);
|
||||
if (this.workspaceId && contentChanged) {
|
||||
const blockedBy = await this.editLockService.getBlockingHolder('document', id);
|
||||
if (blockedBy) {
|
||||
throw new TRPCError({
|
||||
cause: { data: { code: 'DocumentLocked' } },
|
||||
code: 'CONFLICT',
|
||||
message: 'Document is being edited by another user',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const updates: Record<string, unknown> = {};
|
||||
|
||||
if (params.content !== undefined) {
|
||||
@@ -488,9 +390,6 @@ export class DocumentService {
|
||||
updates.parentId = params.parentId;
|
||||
}
|
||||
|
||||
// The lock lease is refreshed by the client heartbeat (acquireDocumentLock),
|
||||
// so a save does not need to touch it.
|
||||
|
||||
let savedAt: Date | undefined;
|
||||
|
||||
if (historyAppended) {
|
||||
@@ -515,25 +414,12 @@ export class DocumentService {
|
||||
await fileModel.update(currentDocument.fileId, fileUpdates);
|
||||
}
|
||||
|
||||
changed = Object.keys(updates).length > 0 || historyAppended;
|
||||
|
||||
return {
|
||||
historyAppended,
|
||||
id,
|
||||
savedAt,
|
||||
};
|
||||
});
|
||||
|
||||
// Notify other workspace members that the document changed so their open
|
||||
// editor refreshes immediately (best-effort; the heartbeat is the fallback).
|
||||
if (this.workspaceId && changed) {
|
||||
void publishResourceEvent(
|
||||
{ id, type: 'document' },
|
||||
{ actorId: this.userId, type: 'doc.updated' },
|
||||
);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -75,12 +75,3 @@ export interface UpdateDocumentResult {
|
||||
export interface SaveDocumentHistoryResult {
|
||||
savedAt: Date;
|
||||
}
|
||||
|
||||
export interface DocumentLockResult {
|
||||
/** Lease expiry of the active lock, if any. */
|
||||
expiresAt: Date | null;
|
||||
/** The user id currently holding the lock, or null when unlocked. */
|
||||
holderId: string | null;
|
||||
/** True when another active user holds the lock (caller is locked out). */
|
||||
lockedByOther: boolean;
|
||||
}
|
||||
|
||||
@@ -1,148 +0,0 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { EditLockService } from '../index';
|
||||
|
||||
/**
|
||||
* Minimal in-memory fake of the ioredis calls EditLockService uses:
|
||||
* `set(k, v, 'EX', ttl[, 'NX'])`, `get(k)`, and the compare-and-delete `eval`.
|
||||
*/
|
||||
const makeFakeRedis = () => {
|
||||
const store = new Map<string, string>();
|
||||
return {
|
||||
eval: vi.fn(async (_script: string, _numKeys: number, key: string, arg: string) => {
|
||||
if (store.get(key) === arg) {
|
||||
store.delete(key);
|
||||
return 1;
|
||||
}
|
||||
return 0;
|
||||
}),
|
||||
get: vi.fn(async (key: string) => store.get(key) ?? null),
|
||||
set: vi.fn(async (key: string, value: string, ...args: unknown[]) => {
|
||||
if (args.includes('NX') && store.has(key)) return null;
|
||||
store.set(key, value);
|
||||
return 'OK';
|
||||
}),
|
||||
store,
|
||||
};
|
||||
};
|
||||
|
||||
describe('EditLockService', () => {
|
||||
it('acquires a free lock and reports the caller as holder', async () => {
|
||||
const redis = makeFakeRedis();
|
||||
const svc = new EditLockService('user-1', redis as any);
|
||||
|
||||
const result = await svc.acquire('document', 'doc-1');
|
||||
|
||||
expect(result.holderId).toBe('user-1');
|
||||
expect(result.lockedByOther).toBe(false);
|
||||
expect(result.expiresAt).toBeInstanceOf(Date);
|
||||
expect(redis.store.get('editlock:document:doc-1')).toBe('user-1');
|
||||
});
|
||||
|
||||
it('reports another member as holder when the lock is already taken', async () => {
|
||||
const redis = makeFakeRedis();
|
||||
await new EditLockService('user-1', redis as any).acquire('document', 'doc-1');
|
||||
|
||||
const result = await new EditLockService('user-2', redis as any).acquire('document', 'doc-1');
|
||||
|
||||
expect(result).toEqual({ expiresAt: null, holderId: 'user-1', lockedByOther: true });
|
||||
});
|
||||
|
||||
it('lets the holder refresh their own lease', async () => {
|
||||
const redis = makeFakeRedis();
|
||||
const svc = new EditLockService('user-1', redis as any);
|
||||
await svc.acquire('document', 'doc-1');
|
||||
|
||||
const result = await svc.acquire('document', 'doc-1');
|
||||
|
||||
expect(result.holderId).toBe('user-1');
|
||||
expect(result.lockedByOther).toBe(false);
|
||||
});
|
||||
|
||||
it('getActiveHolder reports the current holder, or undefined when free', async () => {
|
||||
const redis = makeFakeRedis();
|
||||
expect(
|
||||
await new EditLockService('user-1', redis as any).getActiveHolder('document', 'doc-1'),
|
||||
).toBeUndefined();
|
||||
|
||||
await new EditLockService('user-1', redis as any).acquire('document', 'doc-1');
|
||||
expect(
|
||||
await new EditLockService('user-2', redis as any).getActiveHolder('document', 'doc-1'),
|
||||
).toBe('user-1');
|
||||
});
|
||||
|
||||
it('keys locks per resource type, so the same id does not collide across types', async () => {
|
||||
const redis = makeFakeRedis();
|
||||
await new EditLockService('user-1', redis as any).acquire('document', 'shared-id');
|
||||
|
||||
// A different resource family with the same id is independently lockable.
|
||||
const result = await new EditLockService('user-2', redis as any).acquire('agent', 'shared-id');
|
||||
|
||||
expect(result.holderId).toBe('user-2');
|
||||
expect(result.lockedByOther).toBe(false);
|
||||
expect(redis.store.get('editlock:document:shared-id')).toBe('user-1');
|
||||
expect(redis.store.get('editlock:agent:shared-id')).toBe('user-2');
|
||||
});
|
||||
|
||||
it('getBlockingHolder returns the holder only when it is someone else', async () => {
|
||||
const redis = makeFakeRedis();
|
||||
await new EditLockService('user-1', redis as any).acquire('document', 'doc-1');
|
||||
|
||||
expect(
|
||||
await new EditLockService('user-2', redis as any).getBlockingHolder('document', 'doc-1'),
|
||||
).toBe('user-1');
|
||||
expect(
|
||||
await new EditLockService('user-1', redis as any).getBlockingHolder('document', 'doc-1'),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it('only releases the lock for the current holder', async () => {
|
||||
const redis = makeFakeRedis();
|
||||
await new EditLockService('user-1', redis as any).acquire('document', 'doc-1');
|
||||
|
||||
// A non-holder release is a no-op and reports it did not release.
|
||||
expect(await new EditLockService('user-2', redis as any).release('document', 'doc-1')).toBe(
|
||||
false,
|
||||
);
|
||||
expect(redis.store.get('editlock:document:doc-1')).toBe('user-1');
|
||||
|
||||
// The holder can release, and reports the lock was actually freed.
|
||||
expect(await new EditLockService('user-1', redis as any).release('document', 'doc-1')).toBe(
|
||||
true,
|
||||
);
|
||||
expect(redis.store.has('editlock:document:doc-1')).toBe(false);
|
||||
});
|
||||
|
||||
it('degrades to unlocked / no-op when Redis is unavailable', async () => {
|
||||
const svc = new EditLockService('user-1', null);
|
||||
|
||||
expect(await svc.acquire('document', 'doc-1')).toEqual({
|
||||
expiresAt: null,
|
||||
holderId: null,
|
||||
lockedByOther: false,
|
||||
});
|
||||
expect(await svc.getBlockingHolder('document', 'doc-1')).toBeNull();
|
||||
await expect(svc.release('document', 'doc-1')).resolves.toBe(false);
|
||||
});
|
||||
|
||||
it('fails open when Redis is configured but commands reject (unreachable)', async () => {
|
||||
// ioredis is non-null but every command rejects after retries — the write
|
||||
// guards must not turn this into a 500; treat the resource as unlocked.
|
||||
const down = new Error('Connection is closed.');
|
||||
const redis = {
|
||||
eval: vi.fn().mockRejectedValue(down),
|
||||
get: vi.fn().mockRejectedValue(down),
|
||||
set: vi.fn().mockRejectedValue(down),
|
||||
};
|
||||
const svc = new EditLockService('user-1', redis as any);
|
||||
|
||||
expect(await svc.acquire('document', 'doc-1')).toEqual({
|
||||
expiresAt: null,
|
||||
holderId: null,
|
||||
lockedByOther: false,
|
||||
});
|
||||
expect(await svc.getActiveHolder('document', 'doc-1')).toBeUndefined();
|
||||
expect(await svc.getBlockingHolder('document', 'doc-1')).toBeNull();
|
||||
await expect(svc.release('document', 'doc-1')).resolves.toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -1,153 +0,0 @@
|
||||
import debug from 'debug';
|
||||
import type { Redis } from 'ioredis';
|
||||
|
||||
import { getAgentRuntimeRedisClient } from '@/server/modules/AgentRuntime/redis';
|
||||
|
||||
const log = debug('lobe-server:edit-lock');
|
||||
|
||||
/** Lease lifetime in seconds; clients heartbeat well within this to keep it alive. */
|
||||
export const EDIT_LOCK_TTL_SECONDS = 30;
|
||||
|
||||
/** Editable resource families that can take a collaborative edit lock. */
|
||||
export type EditLockResourceType = 'agent' | 'chatGroup' | 'document' | 'task';
|
||||
|
||||
export interface EditLockResult {
|
||||
/** Lease expiry of the active lock, if the caller now holds it. */
|
||||
expiresAt: Date | null;
|
||||
/** The user id currently holding the lock, or null when unlocked. */
|
||||
holderId: string | null;
|
||||
/** True when another user holds the lock (caller is locked out). */
|
||||
lockedByOther: boolean;
|
||||
}
|
||||
|
||||
const UNLOCKED: EditLockResult = { expiresAt: null, holderId: null, lockedByOther: false };
|
||||
|
||||
const lockKey = (type: EditLockResourceType, id: string) => `editlock:${type}:${id}`;
|
||||
|
||||
// Release only if the caller still holds the lock (compare-and-delete), so a
|
||||
// stale releaser can't drop a lease another member has since taken over.
|
||||
const RELEASE_SCRIPT = `
|
||||
if redis.call('get', KEYS[1]) == ARGV[1] then
|
||||
return redis.call('del', KEYS[1])
|
||||
end
|
||||
return 0
|
||||
`;
|
||||
|
||||
/**
|
||||
* Redis-backed collaborative edit lock, keyed by (resourceType, resourceId).
|
||||
*
|
||||
* Intentionally a thin, table-agnostic lease: there is no DB schema, so it
|
||||
* applies uniformly to any editable resource (documents, briefs, …) and can be
|
||||
* removed wholesale once real-time co-editing lands — the keys simply expire.
|
||||
*
|
||||
* The lock is advisory: when Redis is unavailable every method degrades to
|
||||
* "unlocked" so the lock infrastructure can never block editing or saving.
|
||||
*/
|
||||
export class EditLockService {
|
||||
private userId: string;
|
||||
private explicitRedis: Redis | null | undefined;
|
||||
private lazyRedis: Redis | null = null;
|
||||
private lazyResolved = false;
|
||||
|
||||
constructor(userId: string, redis?: Redis | null) {
|
||||
this.userId = userId;
|
||||
this.explicitRedis = redis;
|
||||
}
|
||||
|
||||
/**
|
||||
* The Redis client, resolved lazily on first use. Resolving eagerly in the
|
||||
* constructor would read server-only env (`getAgentRuntimeRedisClient`) the
|
||||
* moment any owning service is built — which throws in client/test contexts
|
||||
* that construct the service but never take a lock.
|
||||
*/
|
||||
private get redis(): Redis | null {
|
||||
if (this.explicitRedis !== undefined) return this.explicitRedis;
|
||||
if (!this.lazyResolved) {
|
||||
this.lazyRedis = getAgentRuntimeRedisClient();
|
||||
this.lazyResolved = true;
|
||||
}
|
||||
return this.lazyRedis;
|
||||
}
|
||||
|
||||
/**
|
||||
* Acquire the lock when it is free (or already mine), refreshing the lease;
|
||||
* otherwise report whoever currently holds it. Doubles as the heartbeat.
|
||||
*/
|
||||
async acquire(type: EditLockResourceType, id: string): Promise<EditLockResult> {
|
||||
const redis = this.redis;
|
||||
if (!redis) return UNLOCKED;
|
||||
const key = lockKey(type, id);
|
||||
|
||||
try {
|
||||
// Claim only when the key is absent (NX). The TTL gives automatic expiry, so
|
||||
// a hard-closed tab frees the lock without any cleanup job.
|
||||
const claimed = await redis.set(key, this.userId, 'EX', EDIT_LOCK_TTL_SECONDS, 'NX');
|
||||
if (claimed) return this.held();
|
||||
|
||||
const holder = await redis.get(key);
|
||||
if (holder === this.userId) {
|
||||
// Already mine — refresh the lease (heartbeat).
|
||||
await redis.set(key, this.userId, 'EX', EDIT_LOCK_TTL_SECONDS);
|
||||
return this.held();
|
||||
}
|
||||
if (holder) return { expiresAt: null, holderId: holder, lockedByOther: true };
|
||||
|
||||
// Freed between the NX and the GET — try once more.
|
||||
const reclaimed = await redis.set(key, this.userId, 'EX', EDIT_LOCK_TTL_SECONDS, 'NX');
|
||||
return reclaimed ? this.held() : UNLOCKED;
|
||||
} catch (error) {
|
||||
// Fail-open: a Redis outage (configured but unreachable) must never block
|
||||
// editing — report unlocked rather than surfacing the command rejection.
|
||||
log('acquire failed for %s:%s %O', type, id, error);
|
||||
return UNLOCKED;
|
||||
}
|
||||
}
|
||||
|
||||
/** Current holder of the lock, or undefined when unlocked / Redis is down. */
|
||||
async getActiveHolder(type: EditLockResourceType, id: string): Promise<string | undefined> {
|
||||
const redis = this.redis;
|
||||
if (!redis) return undefined;
|
||||
try {
|
||||
const holder = await redis.get(lockKey(type, id));
|
||||
return holder ?? undefined;
|
||||
} catch (error) {
|
||||
// Fail-open: a Redis outage must not turn the write guards into 500s.
|
||||
log('getActiveHolder failed for %s:%s %O', type, id, error);
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The holder when someone *other* than the caller holds the lock, else null.
|
||||
* Used by write guards; returns null when Redis is down (fail-open).
|
||||
*/
|
||||
async getBlockingHolder(type: EditLockResourceType, id: string): Promise<string | null> {
|
||||
const holder = await this.getActiveHolder(type, id);
|
||||
return holder && holder !== this.userId ? holder : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Release the lock, but only if the caller still holds it (compare-and-delete).
|
||||
* Returns true only when the caller's lock was actually deleted — false when
|
||||
* the lease had already expired or another member has since taken it over, so
|
||||
* callers can avoid broadcasting a bogus "unlocked" event.
|
||||
*/
|
||||
async release(type: EditLockResourceType, id: string): Promise<boolean> {
|
||||
if (!this.redis) return false;
|
||||
try {
|
||||
const deleted = await this.redis.eval(RELEASE_SCRIPT, 1, lockKey(type, id), this.userId);
|
||||
return deleted === 1;
|
||||
} catch (error) {
|
||||
log('release failed for %s:%s %O', type, id, error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private held(): EditLockResult {
|
||||
return {
|
||||
expiresAt: new Date(Date.now() + EDIT_LOCK_TTL_SECONDS * 1000),
|
||||
holderId: this.userId,
|
||||
lockedByOther: false,
|
||||
};
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
-374
@@ -1,374 +0,0 @@
|
||||
// @vitest-environment node
|
||||
import type { AgentStreamEvent } from '@lobechat/agent-gateway-client';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import {
|
||||
__resetOperationStatesForTesting,
|
||||
HeterogeneousPersistenceHandler,
|
||||
} from '../HeterogeneousPersistenceHandler';
|
||||
|
||||
/**
|
||||
* Regression for the SERVER-ONLY "大量无意义的 SubAgent" bug.
|
||||
*
|
||||
* Root cause: `HeterogeneousPersistenceHandler` keeps per-operation state in a
|
||||
* module-level `operationStates` map. On Vercel serverless, consecutive ingest
|
||||
* batches for one operation can land on DIFFERENT (cold) replicas, so that map
|
||||
* is empty on the next batch. `loadOrCreateState` rehydrates the MAIN-agent
|
||||
* state from DB (accumulatedContent, toolState, toolMsgIdByCallId,
|
||||
* currentAssistantMessageId) — but initializes `subagentState` with an empty
|
||||
* `createSubagentRunsState()` and NEVER reconstructs the in-flight subagent
|
||||
* runs from DB.
|
||||
*
|
||||
* Consequence: when a subagent run spans multiple batches, the first subagent
|
||||
* event seen by each fresh replica hits the `!existing` branch of `ensureRun`
|
||||
* and creates a BRAND-NEW thread for a `parentToolCallId` that already has one.
|
||||
* The duplicates get the generic "Subagent" title because spawnMetadata only
|
||||
* rides the first subagent event per parent (adapter `announcedSpawns`).
|
||||
*
|
||||
* The desktop client never hits this — it has a single long-lived
|
||||
* `subagentState` closure for the whole run.
|
||||
*
|
||||
* This test simulates a cold replica between batches via
|
||||
* `__resetOperationStatesForTesting()` (the in-memory map is dropped while the
|
||||
* mock DB — `threads` / `messages` — persists, exactly like a fresh Lambda).
|
||||
*/
|
||||
|
||||
interface FakeMessage {
|
||||
agentId: string | null;
|
||||
content: string;
|
||||
id: string;
|
||||
metadata?: any;
|
||||
model?: string;
|
||||
parentId?: string | null;
|
||||
plugin?: any;
|
||||
reasoning?: any;
|
||||
role: 'user' | 'assistant' | 'tool' | 'task' | 'system';
|
||||
threadId?: string | null;
|
||||
tool_call_id?: string;
|
||||
tools?: any[];
|
||||
topicId: string | null;
|
||||
}
|
||||
|
||||
interface FakeThread {
|
||||
id: string;
|
||||
metadata?: any;
|
||||
sourceMessageId?: string | null;
|
||||
status: string;
|
||||
title: string;
|
||||
topicId: string;
|
||||
type: string;
|
||||
}
|
||||
|
||||
const createHarness = (params: {
|
||||
assistantMessageId: string;
|
||||
operationId: string;
|
||||
topicId: string;
|
||||
}) => {
|
||||
let nextMsgIdSeq = 0;
|
||||
const messages = new Map<string, FakeMessage>();
|
||||
const threads = new Map<string, FakeThread>();
|
||||
|
||||
messages.set(params.assistantMessageId, {
|
||||
agentId: null,
|
||||
content: '',
|
||||
id: params.assistantMessageId,
|
||||
role: 'assistant',
|
||||
topicId: params.topicId,
|
||||
});
|
||||
|
||||
const messageModel = {
|
||||
create: vi.fn(async (input: Partial<FakeMessage>, id?: string) => {
|
||||
nextMsgIdSeq += 1;
|
||||
const msgId = id ?? `msg_${nextMsgIdSeq}`;
|
||||
const msg: FakeMessage = {
|
||||
agentId: input.agentId ?? null,
|
||||
content: input.content ?? '',
|
||||
id: msgId,
|
||||
metadata: input.metadata,
|
||||
model: input.model,
|
||||
parentId: input.parentId ?? null,
|
||||
plugin: input.plugin,
|
||||
provider: undefined,
|
||||
reasoning: input.reasoning,
|
||||
role: input.role!,
|
||||
threadId: input.threadId ?? null,
|
||||
tool_call_id: input.tool_call_id,
|
||||
topicId: input.topicId ?? null,
|
||||
} as FakeMessage;
|
||||
messages.set(msgId, msg);
|
||||
return msg;
|
||||
}),
|
||||
update: vi.fn(async (id: string, patch: Partial<FakeMessage>) => {
|
||||
const existing = messages.get(id);
|
||||
if (!existing) return { success: false };
|
||||
messages.set(id, { ...existing, ...patch });
|
||||
return { success: true };
|
||||
}),
|
||||
updateToolMessage: vi.fn(async (id: string, patch: any) => {
|
||||
const existing = messages.get(id);
|
||||
if (!existing) return { success: false };
|
||||
messages.set(id, { ...existing, content: patch.content ?? existing.content });
|
||||
return { success: true };
|
||||
}),
|
||||
findById: vi.fn(async (id: string) => messages.get(id) ?? null),
|
||||
query: vi.fn(async (params: { threadId?: string; topicId?: string }) => {
|
||||
if (params?.threadId) {
|
||||
return [...messages.values()].filter((m) => m.threadId === params.threadId);
|
||||
}
|
||||
return [...messages.values()].filter((m) => !m.threadId && m.topicId === params?.topicId);
|
||||
}),
|
||||
getLastChildToolMessageId: vi.fn(async (assistantMessageId: string) => {
|
||||
const match = [...messages.values()].findLast(
|
||||
(m) => m.role === 'tool' && m.parentId === assistantMessageId && !m.threadId,
|
||||
);
|
||||
return match?.id;
|
||||
}),
|
||||
listMessagePluginsByTopic: vi.fn(async (_topicId: string) => {
|
||||
// Mirror the real query: every persisted tool row's (toolCallId → id).
|
||||
return [...messages.values()]
|
||||
.filter((m) => m.role === 'tool' && m.tool_call_id)
|
||||
.map((m) => ({ id: m.id, toolCallId: m.tool_call_id! }));
|
||||
}),
|
||||
};
|
||||
|
||||
const threadModel = {
|
||||
create: vi.fn(async (input: Partial<FakeThread>) => {
|
||||
const thread: FakeThread = {
|
||||
id: input.id!,
|
||||
metadata: input.metadata,
|
||||
sourceMessageId: input.sourceMessageId,
|
||||
status: input.status ?? 'active',
|
||||
title: input.title ?? '',
|
||||
topicId: input.topicId ?? params.topicId,
|
||||
type: input.type ?? 'isolation',
|
||||
};
|
||||
threads.set(thread.id, thread);
|
||||
return thread;
|
||||
}),
|
||||
findById: vi.fn(async (id: string) => threads.get(id) ?? null),
|
||||
queryByTopicId: vi.fn(async (topicId: string) =>
|
||||
[...threads.values()].filter((t) => t.topicId === topicId),
|
||||
),
|
||||
update: vi.fn(async (id: string, patch: Partial<FakeThread>) => {
|
||||
const existing = threads.get(id);
|
||||
if (!existing) return;
|
||||
threads.set(id, { ...existing, ...patch });
|
||||
}),
|
||||
};
|
||||
|
||||
const topicModel = {
|
||||
findById: vi.fn(async (id: string) => {
|
||||
if (id !== params.topicId) return null;
|
||||
return {
|
||||
agentId: null,
|
||||
id,
|
||||
metadata: {
|
||||
runningOperation: {
|
||||
assistantMessageId: params.assistantMessageId,
|
||||
operationId: params.operationId,
|
||||
},
|
||||
},
|
||||
};
|
||||
}),
|
||||
updateMetadata: vi.fn(async () => {}),
|
||||
};
|
||||
|
||||
const handler = new HeterogeneousPersistenceHandler({
|
||||
messageModel: messageModel as any,
|
||||
threadModel: threadModel as any,
|
||||
topicModel: topicModel as any,
|
||||
});
|
||||
|
||||
return { handler, messages, threadModel, threads };
|
||||
};
|
||||
|
||||
const buildEvent = (
|
||||
type: AgentStreamEvent['type'],
|
||||
stepIndex: number,
|
||||
data: Record<string, unknown>,
|
||||
): AgentStreamEvent => ({
|
||||
data,
|
||||
operationId: 'op-1',
|
||||
stepIndex,
|
||||
timestamp: 1_700_000_000_000 + stepIndex,
|
||||
type,
|
||||
});
|
||||
|
||||
const innerTool = (id: string) => ({
|
||||
apiName: 'Bash',
|
||||
arguments: '{}',
|
||||
id,
|
||||
identifier: 'bash',
|
||||
type: 'default',
|
||||
});
|
||||
|
||||
describe('HeterogeneousPersistenceHandler — subagent run survives a cold replica', () => {
|
||||
beforeEach(() => __resetOperationStatesForTesting());
|
||||
afterEach(() => __resetOperationStatesForTesting());
|
||||
|
||||
it('does NOT spawn a duplicate thread when a later batch of the SAME subagent run lands on a fresh replica', async () => {
|
||||
const h = createHarness({
|
||||
assistantMessageId: 'asst-1',
|
||||
operationId: 'op-1',
|
||||
topicId: 'topic-1',
|
||||
});
|
||||
|
||||
const PARENT = 'tc-spawn-1';
|
||||
|
||||
// ── Batch 1 (replica A): first subagent turn. Carries spawnMetadata, so the
|
||||
// thread is created with a real title. ──
|
||||
await h.handler.ingest({
|
||||
assistantMessageId: 'asst-1',
|
||||
events: [
|
||||
buildEvent('stream_chunk', 0, {
|
||||
chunkType: 'tools_calling',
|
||||
subagent: {
|
||||
parentToolCallId: PARENT,
|
||||
spawnMetadata: {
|
||||
description: 'Explore session/agent topic data model',
|
||||
prompt: 'investigate',
|
||||
subagentType: 'Explore',
|
||||
},
|
||||
subagentMessageId: 'sub-msg-1',
|
||||
},
|
||||
toolsCalling: [innerTool('inner-1')],
|
||||
}),
|
||||
],
|
||||
operationId: 'op-1',
|
||||
topicId: 'topic-1',
|
||||
});
|
||||
|
||||
expect(h.threads.size).toBe(1);
|
||||
|
||||
// ── Cold replica: the warm in-memory operation state is gone, but the DB
|
||||
// (threads + messages) persists. ──
|
||||
__resetOperationStatesForTesting();
|
||||
|
||||
// ── Batch 2 (replica B): the SAME subagent run continues with a new turn.
|
||||
// Mirroring the adapter, this later event carries NO spawnMetadata. ──
|
||||
await h.handler.ingest({
|
||||
assistantMessageId: 'asst-1',
|
||||
events: [
|
||||
buildEvent('stream_chunk', 1, {
|
||||
chunkType: 'tools_calling',
|
||||
subagent: {
|
||||
parentToolCallId: PARENT,
|
||||
subagentMessageId: 'sub-msg-2',
|
||||
},
|
||||
toolsCalling: [innerTool('inner-2')],
|
||||
}),
|
||||
],
|
||||
operationId: 'op-1',
|
||||
topicId: 'topic-1',
|
||||
});
|
||||
|
||||
// The continuation must attach to the EXISTING thread, not fork a new one.
|
||||
expect(h.threads.size).toBe(1);
|
||||
// And we must never produce a generic-titled "Subagent" duplicate.
|
||||
expect([...h.threads.values()].some((t) => t.title === 'Subagent')).toBe(false);
|
||||
});
|
||||
|
||||
// P1: a tools_calling batch reprocessed on a cold replica (BatchIngester
|
||||
// retry, or a turn split across a cold boundary so the cumulative array is
|
||||
// re-seen) must NOT mint a second tool message for an inner tool the run
|
||||
// already persisted. Rehydration restores `lifetimeToolCallIds`, and the
|
||||
// reducer de-dupes against it.
|
||||
it('does NOT re-create an already-persisted inner tool row after a cold replica', async () => {
|
||||
const h = createHarness({
|
||||
assistantMessageId: 'asst-1',
|
||||
operationId: 'op-1',
|
||||
topicId: 'topic-1',
|
||||
});
|
||||
const PARENT = 'tc-spawn-1';
|
||||
|
||||
// Batch 1: turn sub-msg-1 persists inner-1.
|
||||
await h.handler.ingest({
|
||||
assistantMessageId: 'asst-1',
|
||||
events: [
|
||||
buildEvent('stream_chunk', 0, {
|
||||
chunkType: 'tools_calling',
|
||||
subagent: {
|
||||
parentToolCallId: PARENT,
|
||||
spawnMetadata: { prompt: 'go', subagentType: 'Explore' },
|
||||
subagentMessageId: 'sub-msg-1',
|
||||
},
|
||||
toolsCalling: [innerTool('inner-1')],
|
||||
}),
|
||||
],
|
||||
operationId: 'op-1',
|
||||
topicId: 'topic-1',
|
||||
});
|
||||
|
||||
__resetOperationStatesForTesting(); // cold replica
|
||||
|
||||
// Batch 2 (replica B): the SAME turn's cumulative array is re-seen (inner-1
|
||||
// again) plus a new inner-2.
|
||||
await h.handler.ingest({
|
||||
assistantMessageId: 'asst-1',
|
||||
events: [
|
||||
buildEvent('stream_chunk', 1, {
|
||||
chunkType: 'tools_calling',
|
||||
subagent: { parentToolCallId: PARENT, subagentMessageId: 'sub-msg-1' },
|
||||
toolsCalling: [innerTool('inner-1'), innerTool('inner-2')],
|
||||
}),
|
||||
],
|
||||
operationId: 'op-1',
|
||||
topicId: 'topic-1',
|
||||
});
|
||||
|
||||
const toolRows = (callId: string) =>
|
||||
[...h.messages.values()].filter((m) => m.role === 'tool' && m.tool_call_id === callId);
|
||||
// inner-1 persisted exactly once (no duplicate row), inner-2 once.
|
||||
expect(toolRows('inner-1')).toHaveLength(1);
|
||||
expect(toolRows('inner-2')).toHaveLength(1);
|
||||
expect(h.threads.size).toBe(1);
|
||||
});
|
||||
|
||||
// P2: a stale `Processing` isolation thread left by a PRIOR operation on the
|
||||
// same topic must not be rehydrated into — or finalized by — the current
|
||||
// operation. The rehydration is scoped by `metadata.operationId`.
|
||||
it('ignores a stale Processing thread from a different operation on the same topic', async () => {
|
||||
const h = createHarness({
|
||||
assistantMessageId: 'asst-1',
|
||||
operationId: 'op-2',
|
||||
topicId: 'topic-1',
|
||||
});
|
||||
|
||||
// Seed a thread (+ its in-thread assistant) left Processing by op-1.
|
||||
h.threads.set('thd-stale', {
|
||||
id: 'thd-stale',
|
||||
metadata: { operationId: 'op-1', sourceToolCallId: 'tc-old' },
|
||||
sourceMessageId: 'asst-old',
|
||||
status: 'processing',
|
||||
title: 'Old Subagent',
|
||||
topicId: 'topic-1',
|
||||
type: 'isolation',
|
||||
});
|
||||
h.messages.set('stale-asst', {
|
||||
agentId: null,
|
||||
content: '',
|
||||
id: 'stale-asst',
|
||||
parentId: 'asst-old',
|
||||
role: 'assistant',
|
||||
threadId: 'thd-stale',
|
||||
topicId: 'topic-1',
|
||||
} as any);
|
||||
|
||||
// op-2 runs and terminates. The terminal orphan-drain would finalize every
|
||||
// run in the reducer state — so if the stale thread were merged in, it would
|
||||
// be flipped to Active here.
|
||||
await h.handler.ingest({
|
||||
assistantMessageId: 'asst-1',
|
||||
events: [
|
||||
buildEvent('stream_chunk', 0, { chunkType: 'text', content: 'working' }),
|
||||
buildEvent('agent_runtime_end', 1, {}),
|
||||
],
|
||||
operationId: 'op-2',
|
||||
topicId: 'topic-1',
|
||||
});
|
||||
|
||||
// The unrelated thread is untouched: still Processing, never updated.
|
||||
expect(h.threads.get('thd-stale')!.status).toBe('processing');
|
||||
expect(h.threadModel.update).not.toHaveBeenCalledWith('thd-stale', expect.anything());
|
||||
});
|
||||
});
|
||||
+2
-87
@@ -486,9 +486,9 @@ describe('HeterogeneousPersistenceHandler', () => {
|
||||
if (id === 'asst-1') order.push('update-asst');
|
||||
return origUpdate(id, patch);
|
||||
});
|
||||
h.messageModel.create.mockImplementation(async (input: any, id?: string) => {
|
||||
h.messageModel.create.mockImplementation(async (input: any) => {
|
||||
order.push(input.role === 'tool' ? 'create-tool' : 'create-other');
|
||||
return origCreate(input, id);
|
||||
return origCreate(input);
|
||||
});
|
||||
|
||||
const tool = {
|
||||
@@ -767,91 +767,6 @@ describe('HeterogeneousPersistenceHandler', () => {
|
||||
expect(step2Asst!.parentId).toBe('tool-row-only');
|
||||
});
|
||||
|
||||
it('chains off the latest tool row when parallel tools are only partially backfilled', async () => {
|
||||
// Regression for main-chain breaks with parallel/multi tool calls:
|
||||
// tool A is visible in assistant.tools[].result_msg_id, while tool B's
|
||||
// row exists but Phase 3 has not backfilled assistant.tools[] yet. The
|
||||
// step anchor must be tool B, not the earlier resolved tool A.
|
||||
const h = createHarness({
|
||||
assistantMessageId: 'asst-init',
|
||||
operationId: 'op-1',
|
||||
topicId: 'topic-1',
|
||||
});
|
||||
|
||||
const metaState: FakeTopicMetadata = {
|
||||
runningOperation: { assistantMessageId: 'asst-init', operationId: 'op-1' },
|
||||
};
|
||||
h.topicModel.findById.mockImplementation(async (id: string) => {
|
||||
if (id !== 'topic-1') return null;
|
||||
return { agentId: null, id, metadata: { ...metaState } };
|
||||
});
|
||||
h.topicModel.updateMetadata.mockImplementation(async (_id: string, patch: any) => {
|
||||
Object.assign(metaState, patch);
|
||||
});
|
||||
|
||||
await h.handler.ingest({
|
||||
events: [buildEvent('stream_start', 1, { newStep: true })],
|
||||
operationId: 'op-1',
|
||||
topicId: 'topic-1',
|
||||
});
|
||||
const step1Asst = [...h.messages.values()].find(
|
||||
(m) => m.role === 'assistant' && m.id !== 'asst-init',
|
||||
)!;
|
||||
|
||||
h.messages.set('tool-a-backfilled', {
|
||||
agentId: null,
|
||||
content: 'tool A result',
|
||||
id: 'tool-a-backfilled',
|
||||
parentId: step1Asst.id,
|
||||
role: 'tool',
|
||||
threadId: null,
|
||||
tool_call_id: 'tc-a',
|
||||
topicId: 'topic-1',
|
||||
});
|
||||
h.messages.set('tool-b-row-only', {
|
||||
agentId: null,
|
||||
content: 'tool B result',
|
||||
id: 'tool-b-row-only',
|
||||
parentId: step1Asst.id,
|
||||
role: 'tool',
|
||||
threadId: null,
|
||||
tool_call_id: 'tc-b',
|
||||
topicId: 'topic-1',
|
||||
});
|
||||
h.messages.set(step1Asst.id, {
|
||||
...h.messages.get(step1Asst.id)!,
|
||||
tools: [
|
||||
{
|
||||
apiName: 'Read',
|
||||
arguments: '{}',
|
||||
id: 'tc-a',
|
||||
identifier: 'read',
|
||||
result_msg_id: 'tool-a-backfilled',
|
||||
type: 'default',
|
||||
},
|
||||
{
|
||||
apiName: 'Bash',
|
||||
arguments: '{}',
|
||||
id: 'tc-b',
|
||||
identifier: 'bash',
|
||||
type: 'default',
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
await h.handler.ingest({
|
||||
events: [buildEvent('stream_start', 2, { newStep: true })],
|
||||
operationId: 'op-1',
|
||||
topicId: 'topic-1',
|
||||
});
|
||||
|
||||
const step2Asst = [...h.messages.values()].find(
|
||||
(m) => m.role === 'assistant' && m.id !== 'asst-init' && m.id !== step1Asst.id,
|
||||
);
|
||||
expect(step2Asst).toBeDefined();
|
||||
expect(step2Asst!.parentId).toBe('tool-b-row-only');
|
||||
});
|
||||
|
||||
it('ignores subagent tool rows (threadId set) when resolving the step anchor', async () => {
|
||||
// A subagent tool row lives on its own thread and must never anchor the
|
||||
// main-agent wire. If the only `role:'tool'` child carries a threadId,
|
||||
|
||||
@@ -47,6 +47,10 @@ export interface SandboxRunParams {
|
||||
userId: string;
|
||||
}
|
||||
|
||||
export interface SandboxRunResult {
|
||||
commandId?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Derive the local directory name from a repo identifier.
|
||||
* Accepts "owner/repo", "https://github.com/owner/repo", or "https://github.com/owner/repo.git".
|
||||
@@ -121,7 +125,7 @@ function buildRepoSetupScript(repos: string[], githubToken?: string): string | n
|
||||
* Fire-and-forget: the caller does NOT await this — the sandbox pushes events
|
||||
* back to the server via `heteroIngest` tRPC batches independently.
|
||||
*/
|
||||
export async function spawnHeteroSandbox(params: SandboxRunParams): Promise<void> {
|
||||
export async function spawnHeteroSandbox(params: SandboxRunParams): Promise<SandboxRunResult> {
|
||||
const {
|
||||
agentType,
|
||||
assistantMessageId,
|
||||
@@ -215,4 +219,16 @@ export async function spawnHeteroSandbox(params: SandboxRunParams): Promise<void
|
||||
if (!result.success) {
|
||||
throw new Error(result.error?.message || 'Failed to spawn heterogeneous sandbox');
|
||||
}
|
||||
|
||||
const resultData = result.result;
|
||||
const commandId =
|
||||
resultData && typeof resultData === 'object'
|
||||
? String(
|
||||
(resultData as Record<string, unknown>).commandId ||
|
||||
(resultData as Record<string, unknown>).shell_id ||
|
||||
'',
|
||||
) || undefined
|
||||
: undefined;
|
||||
|
||||
return { commandId };
|
||||
}
|
||||
|
||||
@@ -1,25 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { publishResourceEvent, resourceChannelId } from '../index';
|
||||
|
||||
describe('resourceEvents', () => {
|
||||
it('formats a stable channel id per resource', () => {
|
||||
expect(resourceChannelId({ id: 'doc-1', type: 'document' })).toBe('resource:document:doc-1');
|
||||
});
|
||||
|
||||
it('publish is best-effort and never throws (no Redis → in-memory)', async () => {
|
||||
await expect(
|
||||
publishResourceEvent(
|
||||
{ id: 'doc-1', type: 'document' },
|
||||
{ actorId: 'u1', type: 'doc.updated' },
|
||||
),
|
||||
).resolves.toBeUndefined();
|
||||
|
||||
await expect(
|
||||
publishResourceEvent(
|
||||
{ id: 'doc-1', type: 'document' },
|
||||
{ actorId: 'u1', data: { holderId: null }, type: 'lock.changed' },
|
||||
),
|
||||
).resolves.toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -1,82 +0,0 @@
|
||||
import debug from 'debug';
|
||||
|
||||
// Import the transport pieces from their concrete modules rather than the
|
||||
// `@/server/modules/AgentRuntime` barrel: the barrel re-exports RuntimeExecutors,
|
||||
// which eagerly constructs the ModelRuntime ApiKeyManager at module load and
|
||||
// throws in client/test contexts. These leaf modules pull no ModelRuntime.
|
||||
import { inMemoryStreamEventManager } from '@/server/modules/AgentRuntime/InMemoryStreamEventManager';
|
||||
import { getAgentRuntimeRedisClient } from '@/server/modules/AgentRuntime/redis';
|
||||
import { StreamEventManager } from '@/server/modules/AgentRuntime/StreamEventManager';
|
||||
import type { IStreamEventManager } from '@/server/modules/AgentRuntime/types';
|
||||
|
||||
import type { ReceivedResourceEvent, ResourceEvent, ResourceRef } from './types';
|
||||
|
||||
export type { ReceivedResourceEvent, ResourceEvent, ResourceRef, ResourceType } from './types';
|
||||
|
||||
const log = debug('lobe-server:resource-events');
|
||||
|
||||
/** Redis Stream / in-memory channel key for a resource. */
|
||||
export const resourceChannelId = (ref: ResourceRef): string => `resource:${ref.type}:${ref.id}`;
|
||||
|
||||
/**
|
||||
* Select the underlying transport. We deliberately bypass
|
||||
* `createStreamEventManager()` — its `GatewayStreamNotifier` wrapper POSTs every
|
||||
* published event to the agent gateway, which must not see resource events.
|
||||
* Evaluated per call so it picks up Redis becoming (un)available.
|
||||
*/
|
||||
const getManager = (): IStreamEventManager =>
|
||||
getAgentRuntimeRedisClient() !== null ? new StreamEventManager() : inMemoryStreamEventManager;
|
||||
|
||||
/**
|
||||
* Realtime event fan-out for editable resources, keyed by (resourceType, id).
|
||||
*
|
||||
* A thin, table-agnostic wrapper over the existing Redis-Streams transport so
|
||||
* presence and (eventually) real-time co-editing can reuse the same channel.
|
||||
* The lease/lock is advisory and this channel is best-effort: publishing never
|
||||
* throws, and with no Redis the in-memory manager keeps single-instance dev
|
||||
* working while clients fall back to their polling heartbeat.
|
||||
*/
|
||||
export const publishResourceEvent = async (
|
||||
ref: ResourceRef,
|
||||
event: ResourceEvent,
|
||||
): Promise<void> => {
|
||||
try {
|
||||
await getManager().publishStreamEvent(resourceChannelId(ref), {
|
||||
// The agent StreamEvent shape (stepIndex + closed `type` union) is an
|
||||
// implementation detail of the transport; cast at this single boundary.
|
||||
data: { actorId: event.actorId, ...event.data },
|
||||
stepIndex: 0,
|
||||
type: event.type,
|
||||
} as unknown as Parameters<IStreamEventManager['publishStreamEvent']>[1]);
|
||||
} catch (error) {
|
||||
// Best-effort: a transport hiccup must never break the caller's save/lock op.
|
||||
log('publishResourceEvent failed for %s:%s %O', ref.type, ref.id, error);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Subscribe to a resource's events until `signal` aborts. Only events published
|
||||
* after subscription are delivered (no history replay).
|
||||
*/
|
||||
export const subscribeResourceEvents = async (
|
||||
ref: ResourceRef,
|
||||
onEvent: (event: ReceivedResourceEvent) => void,
|
||||
signal: AbortSignal,
|
||||
): Promise<void> => {
|
||||
await getManager().subscribeStreamEvents(
|
||||
resourceChannelId(ref),
|
||||
'$',
|
||||
(events) => {
|
||||
for (const e of events) {
|
||||
const { actorId, ...rest } = (e.data ?? {}) as Record<string, unknown>;
|
||||
onEvent({
|
||||
actorId: typeof actorId === 'string' ? actorId : '',
|
||||
data: rest,
|
||||
timestamp: e.timestamp,
|
||||
type: e.type as unknown as ResourceEvent['type'],
|
||||
});
|
||||
}
|
||||
},
|
||||
signal,
|
||||
);
|
||||
};
|
||||
@@ -1,21 +0,0 @@
|
||||
/** Editable resource families that can broadcast realtime events. */
|
||||
export type ResourceType = 'agent' | 'chatGroup' | 'document' | 'task';
|
||||
|
||||
export interface ResourceRef {
|
||||
id: string;
|
||||
type: ResourceType;
|
||||
}
|
||||
|
||||
export type ResourceEventType = 'doc.updated' | 'lock.changed';
|
||||
|
||||
export interface ResourceEvent {
|
||||
/** User id that triggered the event; lets subscribers ignore self-originated events. */
|
||||
actorId: string;
|
||||
/** Event-specific payload (e.g. `{ holderId }` for `lock.changed`). */
|
||||
data?: Record<string, unknown>;
|
||||
type: ResourceEventType;
|
||||
}
|
||||
|
||||
export interface ReceivedResourceEvent extends ResourceEvent {
|
||||
timestamp: number;
|
||||
}
|
||||
@@ -706,7 +706,6 @@ export class TaskService {
|
||||
activities: activities.length > 0 ? activities : undefined,
|
||||
topicCount: topics.length > 0 ? topics.length : undefined,
|
||||
workspace: workspaceFolders.length > 0 ? workspaceFolders : undefined,
|
||||
workspaceId: task.workspaceId ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -370,14 +370,6 @@
|
||||
"noMatchingAgents": "لم يتم العثور على أعضاء مطابقين",
|
||||
"noMembersYet": "لا تحتوي هذه المجموعة على أي أعضاء بعد. انقر على زر + لدعوة وكلاء.",
|
||||
"noSelectedAgents": "لم يتم تحديد أي أعضاء بعد",
|
||||
"opStatusTray.cost": "التكلفة",
|
||||
"opStatusTray.status.compressing": "ضغط السياق",
|
||||
"opStatusTray.status.generating": "جاري التوليد",
|
||||
"opStatusTray.status.reasoning": "التفكير",
|
||||
"opStatusTray.status.searching": "جاري البحث",
|
||||
"opStatusTray.status.toolCalling": "استدعاء الأدوات",
|
||||
"opStatusTray.steps": "الخطوات",
|
||||
"opStatusTray.tokens": "الرموز",
|
||||
"openInNewWindow": "فتح في نافذة جديدة",
|
||||
"operation.contextCompression": "السياق طويل جدًا، يتم ضغط السجل...",
|
||||
"operation.execAgentRuntime": "جارٍ تحضير الرد",
|
||||
|
||||
@@ -17,10 +17,6 @@
|
||||
"workingDirectory.createBranchAction": "تبديل إلى فرع جديد…",
|
||||
"workingDirectory.createBranchTitle": "إنشاء فرع جديد",
|
||||
"workingDirectory.current": "دليل العمل الحالي",
|
||||
"workingDirectory.deleteBranchAction": "حذف الفرع",
|
||||
"workingDirectory.deleteBranchConfirm": "هل تريد حذف الفرع “{{name}}”؟ سيؤدي ذلك إلى إزالته نهائيًا، بما في ذلك أي عمليات دمج غير مكتملة.",
|
||||
"workingDirectory.deleteBranchTitle": "حذف الفرع",
|
||||
"workingDirectory.deleteFailed": "فشل الحذف",
|
||||
"workingDirectory.detachedHead": "رأس منفصل عند {{sha}}",
|
||||
"workingDirectory.diffStatTooltip": "تمت الإضافة {{added}} · تم التعديل {{modified}} · تم الحذف {{deleted}}",
|
||||
"workingDirectory.filesAdded": "تمت الإضافة",
|
||||
@@ -50,9 +46,6 @@
|
||||
"workingDirectory.recent": "حديث",
|
||||
"workingDirectory.refreshGitStatus": "تحديث حالة الفرع وطلبات السحب",
|
||||
"workingDirectory.removeRecent": "إزالة من الحديث",
|
||||
"workingDirectory.renameBranchAction": "إعادة تسمية الفرع",
|
||||
"workingDirectory.renameBranchTitle": "إعادة تسمية الفرع",
|
||||
"workingDirectory.renameFailed": "فشل إعادة التسمية",
|
||||
"workingDirectory.selectFolder": "اختر مجلدًا",
|
||||
"workingDirectory.title": "دليل العمل",
|
||||
"workingDirectory.topicDescription": "تجاوز الإعداد الافتراضي للوكيل لهذه المحادثة فقط",
|
||||
|
||||
@@ -94,9 +94,6 @@
|
||||
"pageEditor.deleteSuccess": "تم حذف الصفحة بنجاح",
|
||||
"pageEditor.duplicateError": "فشل في تكرار الصفحة",
|
||||
"pageEditor.duplicateSuccess": "تم تكرار الصفحة بنجاح",
|
||||
"pageEditor.editMode.checking": "جارٍ التحقق من توفر التعديل…",
|
||||
"pageEditor.editMode.lockedByOther": "{{name}} يقوم بتعديل هذا المستند",
|
||||
"pageEditor.editMode.lockedBySomeone": "شخص آخر يقوم بتعديل هذا المستند",
|
||||
"pageEditor.editedAt": "آخر تعديل في {{time}}",
|
||||
"pageEditor.editedBy": "آخر تعديل بواسطة {{name}}",
|
||||
"pageEditor.editorPlaceholder": "اضغط \"/\" للوصول إلى الذكاء الاصطناعي والأوامر",
|
||||
@@ -134,8 +131,6 @@
|
||||
"pageEditor.history.versionCount_one": "نسخة واحدة {{count}}",
|
||||
"pageEditor.history.versionCount_other": "{{count}} نسخ",
|
||||
"pageEditor.linkCopied": "تم نسخ الرابط",
|
||||
"pageEditor.lock.editingByOther": "{{name}} يقوم بتعديل هذه الصفحة. لا يمكن حفظ تغييراتك الآن.",
|
||||
"pageEditor.lock.editingBySomeone": "شخص آخر يقوم بتعديل هذه الصفحة. لا يمكن حفظ تغييراتك الآن.",
|
||||
"pageEditor.menu.copyLink": "نسخ الرابط",
|
||||
"pageEditor.menu.export": "تصدير",
|
||||
"pageEditor.menu.export.markdown": "Markdown",
|
||||
|
||||
@@ -307,8 +307,6 @@
|
||||
"providerModels.list.enabledActions.sort": "ترتيب النماذج المخصصة",
|
||||
"providerModels.list.enabledEmpty": "لا توجد نماذج مفعلة. يرجى تفعيل النماذج المفضلة من القائمة أدناه~",
|
||||
"providerModels.list.fetcher.clear": "مسح النماذج المسحوبة",
|
||||
"providerModels.list.fetcher.error": "فشل في جلب النماذج: {{message}}",
|
||||
"providerModels.list.fetcher.errorFallback": "خطأ غير معروف",
|
||||
"providerModels.list.fetcher.fetch": "جلب النماذج",
|
||||
"providerModels.list.fetcher.fetching": "جارٍ جلب قائمة النماذج...",
|
||||
"providerModels.list.fetcher.latestTime": "آخر تحديث: {{time}}",
|
||||
|
||||
@@ -35,7 +35,6 @@
|
||||
"QuotaLimitReached": "عذرًا، تم الوصول إلى الحد الأقصى لاستخدام الرموز أو عدد الطلبات لهذه المفتاح. يرجى زيادة حصة المفتاح أو إعادة المحاولة لاحقًا.",
|
||||
"RateLimitExceeded": "عذرًا، تم الوصول إلى الحد الأقصى لاستخدام الرموز أو عدد الطلبات لهذه المفتاح. يرجى إعادة المحاولة لاحقًا أو زيادة حصة المفتاح.",
|
||||
"StateStorePersistError": "تسبب مشكلة مؤقتة في تخزين حالة المحادثة في تعطيل هذه العملية. يرجى إعادة المحاولة؛ إذا استمرت المشكلة، يرجى الاتصال بالدعم.",
|
||||
"StateStoreReadError": "تعذر استئناف هذه العملية لأن حالة الجلسة غير متوفرة. يرجى إعادة فتح المحادثة للمتابعة؛ وإذا استمرت المشكلة، يرجى الاتصال بالدعم.",
|
||||
"StreamChunkError": "حدث خطأ أثناء تحليل جزء الرسالة من الطلب المتدفق. يرجى التحقق مما إذا كانت واجهة API الحالية تتوافق مع المواصفات القياسية، أو الاتصال بمزود API للحصول على المساعدة.",
|
||||
"UpstreamGatewayError": "عاد البوابة أو الوكيل المصدر بخطأ. يرجى إعادة المحاولة قريبًا؛ إذا استمرت المشكلة، تحقق من إعدادات الوكيل / نقطة النهاية.",
|
||||
"UpstreamHttpError": "عاد المزود بخطأ HTTP دون تفاصيل إضافية. يرجى إعادة المحاولة، أو التحقق من الطلب وإعدادات النموذج.",
|
||||
|
||||
@@ -1,34 +0,0 @@
|
||||
{
|
||||
"generatingPhrases": [
|
||||
"يعمل",
|
||||
"يُصمم",
|
||||
"يفكر",
|
||||
"يحسب",
|
||||
"يُخمّر",
|
||||
"يُركّب",
|
||||
"يُحلل",
|
||||
"يُهندس",
|
||||
"يؤلف",
|
||||
"يُنسق",
|
||||
"يرسم",
|
||||
"يُبدع",
|
||||
"يتأمل",
|
||||
"يصنع",
|
||||
"يُشعل",
|
||||
"يُغلي ببطء",
|
||||
"يُدور",
|
||||
"يُسيطر",
|
||||
"يُلمع",
|
||||
"يُجهز الإجابة",
|
||||
"يخبز",
|
||||
"يُوجه",
|
||||
"يُدمج",
|
||||
"يُفك الشيفرة",
|
||||
"يُصنع",
|
||||
"يُوائم",
|
||||
"يُرتجل",
|
||||
"يستنتج",
|
||||
"يُجرب",
|
||||
"يتعرج"
|
||||
]
|
||||
}
|
||||
@@ -1186,9 +1186,6 @@
|
||||
"tools.klavis.notEnabled": "خدمة Klavis غير مفعلة",
|
||||
"tools.klavis.oauthRequired": "يرجى إكمال التحقق من OAuth في النافذة الجديدة",
|
||||
"tools.klavis.pendingAuth": "في انتظار التحقق",
|
||||
"tools.klavis.remove": "إزالة",
|
||||
"tools.klavis.removeConfirm.desc": "سيتم إزالة {{name}} نهائيًا من خدماتك المتصلة. لا يمكن التراجع عن هذا الإجراء.",
|
||||
"tools.klavis.removeConfirm.title": "إزالة {{name}}؟",
|
||||
"tools.klavis.serverCreated": "تم إنشاء الخادم بنجاح",
|
||||
"tools.klavis.serverCreatedFailed": "فشل في إنشاء الخادم",
|
||||
"tools.klavis.serverRemoved": "تمت إزالة الخادم",
|
||||
|
||||
@@ -135,12 +135,6 @@
|
||||
"management.view.card": "بطاقة",
|
||||
"management.view.list": "قائمة",
|
||||
"newTopic": "موضوع جديد",
|
||||
"projectStatus.failed_one": "{{count}} موضوع فشل",
|
||||
"projectStatus.failed_other": "{{count}} مواضيع فشلت",
|
||||
"projectStatus.loading_one": "{{count}} موضوع قيد التحميل",
|
||||
"projectStatus.loading_other": "{{count}} مواضيع قيد التحميل",
|
||||
"projectStatus.waitingForHuman_one": "{{count}} موضوع ينتظر الإدخال",
|
||||
"projectStatus.waitingForHuman_other": "{{count}} مواضيع تنتظر الإدخال",
|
||||
"renameModal.description": "يُفضَّل أن يكون قصيرًا وسهل التعرّف.",
|
||||
"renameModal.title": "إعادة تسمية الموضوع",
|
||||
"searchPlaceholder": "ابحث في المواضيع...",
|
||||
|
||||
@@ -370,14 +370,6 @@
|
||||
"noMatchingAgents": "Няма съвпадащи членове",
|
||||
"noMembersYet": "Тази група все още няма членове. Натиснете бутона +, за да поканите агенти.",
|
||||
"noSelectedAgents": "Все още няма избрани членове",
|
||||
"opStatusTray.cost": "разход",
|
||||
"opStatusTray.status.compressing": "Компресиране на контекста",
|
||||
"opStatusTray.status.generating": "Генериране",
|
||||
"opStatusTray.status.reasoning": "Разсъждаване",
|
||||
"opStatusTray.status.searching": "Търсене",
|
||||
"opStatusTray.status.toolCalling": "Извикване на инструменти",
|
||||
"opStatusTray.steps": "стъпки",
|
||||
"opStatusTray.tokens": "токени",
|
||||
"openInNewWindow": "Отвори в нов прозорец",
|
||||
"operation.contextCompression": "Контекстът е твърде дълъг, компресиране на историята...",
|
||||
"operation.execAgentRuntime": "Подготвяне на отговор",
|
||||
|
||||
@@ -17,10 +17,6 @@
|
||||
"workingDirectory.createBranchAction": "Превключване към нов клон…",
|
||||
"workingDirectory.createBranchTitle": "Създаване на нов клон",
|
||||
"workingDirectory.current": "Текуща работна директория",
|
||||
"workingDirectory.deleteBranchAction": "Изтриване на клон",
|
||||
"workingDirectory.deleteBranchConfirm": "Да изтрия ли клона „{{name}}“? Това ще го премахне окончателно, включително всички несляти комити.",
|
||||
"workingDirectory.deleteBranchTitle": "Изтриване на клон",
|
||||
"workingDirectory.deleteFailed": "Неуспешно изтриване",
|
||||
"workingDirectory.detachedHead": "Отделен HEAD на {{sha}}",
|
||||
"workingDirectory.diffStatTooltip": "Добавени {{added}} · Модифицирани {{modified}} · Изтрити {{deleted}}",
|
||||
"workingDirectory.filesAdded": "Добавени",
|
||||
@@ -50,9 +46,6 @@
|
||||
"workingDirectory.recent": "Скорошни",
|
||||
"workingDirectory.refreshGitStatus": "Обновяване на статус на клона и PR",
|
||||
"workingDirectory.removeRecent": "Премахване от скорошни",
|
||||
"workingDirectory.renameBranchAction": "Преименуване на клон",
|
||||
"workingDirectory.renameBranchTitle": "Преименуване на клон",
|
||||
"workingDirectory.renameFailed": "Неуспешно преименуване",
|
||||
"workingDirectory.selectFolder": "Изберете папка",
|
||||
"workingDirectory.title": "Работна директория",
|
||||
"workingDirectory.topicDescription": "Замяна на настройката по подразбиране на агента само за този разговор",
|
||||
|
||||
@@ -94,9 +94,6 @@
|
||||
"pageEditor.deleteSuccess": "Страницата е изтрита успешно",
|
||||
"pageEditor.duplicateError": "Неуспешно дублиране на страницата",
|
||||
"pageEditor.duplicateSuccess": "Страницата е дублирана успешно",
|
||||
"pageEditor.editMode.checking": "Проверка на наличността за редактиране…",
|
||||
"pageEditor.editMode.lockedByOther": "{{name}} редактира този документ",
|
||||
"pageEditor.editMode.lockedBySomeone": "Някой друг редактира този документ",
|
||||
"pageEditor.editedAt": "Последна редакция на {{time}}",
|
||||
"pageEditor.editedBy": "Последна редакция от {{name}}",
|
||||
"pageEditor.editorPlaceholder": "Натиснете \"/\" за ИИ и команди",
|
||||
@@ -134,8 +131,6 @@
|
||||
"pageEditor.history.versionCount_one": "{{count}} версия",
|
||||
"pageEditor.history.versionCount_other": "{{count}} версии",
|
||||
"pageEditor.linkCopied": "Връзката е копирана",
|
||||
"pageEditor.lock.editingByOther": "{{name}} редактира тази страница. Вашите промени не могат да бъдат запазени в момента.",
|
||||
"pageEditor.lock.editingBySomeone": "Някой друг редактира тази страница. Вашите промени не могат да бъдат запазени в момента.",
|
||||
"pageEditor.menu.copyLink": "Копирай връзка",
|
||||
"pageEditor.menu.export": "Експортирай",
|
||||
"pageEditor.menu.export.markdown": "Markdown",
|
||||
|
||||
@@ -307,8 +307,6 @@
|
||||
"providerModels.list.enabledActions.sort": "Сортиране на персонализирани модели",
|
||||
"providerModels.list.enabledEmpty": "Няма активирани модели. Моля, активирайте предпочитаните модели от списъка по-долу~",
|
||||
"providerModels.list.fetcher.clear": "Изчисти изтеглените модели",
|
||||
"providerModels.list.fetcher.error": "Неуспешно извличане на модели: {{message}}",
|
||||
"providerModels.list.fetcher.errorFallback": "Неизвестна грешка",
|
||||
"providerModels.list.fetcher.fetch": "Изтегли модели",
|
||||
"providerModels.list.fetcher.fetching": "Изтегляне на списък с модели...",
|
||||
"providerModels.list.fetcher.latestTime": "Последна актуализация: {{time}}",
|
||||
|
||||
@@ -35,7 +35,6 @@
|
||||
"QuotaLimitReached": "Съжаляваме, използването на токени или броят на заявките достигна лимита на квотата за този ключ. Моля, увеличете квотата на ключа или опитайте отново по-късно.",
|
||||
"RateLimitExceeded": "Съжаляваме, използването на токени или броят на заявките достигна лимита на скоростта за този ключ. Моля, опитайте отново по-късно или увеличете квотата на ключа.",
|
||||
"StateStorePersistError": "Временен проблем с хранилището на състоянието на разговора прекъсна тази операция. Моля, опитайте отново; ако проблемът продължи, свържете се с поддръжката.",
|
||||
"StateStoreReadError": "Тази операция не може да бъде възобновена, защото състоянието на сесията не е налично. Моля, отворете разговора отново, за да продължите; ако проблемът продължава, свържете се с поддръжката.",
|
||||
"StreamChunkError": "Грешка при анализиране на част от съобщението в заявката за стрийминг. Моля, проверете дали текущият API интерфейс отговаря на стандартните спецификации или се свържете с вашия доставчик на API за помощ.",
|
||||
"UpstreamGatewayError": "Горният шлюз или прокси върна грешка. Моля, опитайте отново след малко; ако проблемът продължи, проверете конфигурацията на вашето прокси/крайна точка.",
|
||||
"UpstreamHttpError": "Доставчикът върна HTTP грешка без допълнителни подробности. Моля, опитайте отново или проверете вашата заявка и конфигурацията на модела.",
|
||||
|
||||
@@ -1,34 +0,0 @@
|
||||
{
|
||||
"generatingPhrases": [
|
||||
"Работя",
|
||||
"Скицирам",
|
||||
"Мисля",
|
||||
"Изчислявам",
|
||||
"Приготвям",
|
||||
"Синтезирам",
|
||||
"Смятам",
|
||||
"Проектирам",
|
||||
"Съставям",
|
||||
"Оркестрирам",
|
||||
"Рисувам",
|
||||
"Импровизирам",
|
||||
"Размишлявам",
|
||||
"Създавам",
|
||||
"Фламбирам",
|
||||
"Задушавам",
|
||||
"Въртя",
|
||||
"Овладявам",
|
||||
"Полиране",
|
||||
"Подготвям отговора",
|
||||
"Пека",
|
||||
"Канализирам",
|
||||
"Обединявам",
|
||||
"Разшифровам",
|
||||
"Изковавам",
|
||||
"Хармонизирам",
|
||||
"Импровизирам",
|
||||
"Извеждам",
|
||||
"Пипам",
|
||||
"Зигзагообразно"
|
||||
]
|
||||
}
|
||||
@@ -1186,9 +1186,6 @@
|
||||
"tools.klavis.notEnabled": "Услугата Klavis не е активирана",
|
||||
"tools.klavis.oauthRequired": "Моля, завършете OAuth удостоверяването в нов прозорец",
|
||||
"tools.klavis.pendingAuth": "Изчаква удостоверяване",
|
||||
"tools.klavis.remove": "Премахни",
|
||||
"tools.klavis.removeConfirm.desc": "{{name}} ще бъде окончателно премахнат от вашите свързани услуги. Това действие не може да бъде отменено.",
|
||||
"tools.klavis.removeConfirm.title": "Премахване на {{name}}?",
|
||||
"tools.klavis.serverCreated": "Сървърът е създаден успешно",
|
||||
"tools.klavis.serverCreatedFailed": "Неуспешно създаване на сървър",
|
||||
"tools.klavis.serverRemoved": "Сървърът е премахнат",
|
||||
|
||||
@@ -135,12 +135,6 @@
|
||||
"management.view.card": "Карти",
|
||||
"management.view.list": "Списък",
|
||||
"newTopic": "Нова тема",
|
||||
"projectStatus.failed_one": "{{count}} неуспешна тема",
|
||||
"projectStatus.failed_other": "{{count}} неуспешни теми",
|
||||
"projectStatus.loading_one": "{{count}} зареждаща се тема",
|
||||
"projectStatus.loading_other": "{{count}} зареждащи се теми",
|
||||
"projectStatus.waitingForHuman_one": "{{count}} тема, очакваща въвеждане",
|
||||
"projectStatus.waitingForHuman_other": "{{count}} теми, очакващи въвеждане",
|
||||
"renameModal.description": "Поддържайте го кратко и лесно за разпознаване.",
|
||||
"renameModal.title": "Преименуване на тема",
|
||||
"searchPlaceholder": "Търсене в темите...",
|
||||
|
||||
@@ -370,14 +370,6 @@
|
||||
"noMatchingAgents": "Keine passenden Mitglieder gefunden",
|
||||
"noMembersYet": "Diese Gruppe hat noch keine Mitglieder. Klicke auf +, um Agenten einzuladen.",
|
||||
"noSelectedAgents": "Noch keine Mitglieder ausgewählt",
|
||||
"opStatusTray.cost": "Kosten",
|
||||
"opStatusTray.status.compressing": "Kontext komprimieren",
|
||||
"opStatusTray.status.generating": "Generieren",
|
||||
"opStatusTray.status.reasoning": "Nachdenken",
|
||||
"opStatusTray.status.searching": "Suchen",
|
||||
"opStatusTray.status.toolCalling": "Werkzeuge aufrufen",
|
||||
"opStatusTray.steps": "Schritte",
|
||||
"opStatusTray.tokens": "Token",
|
||||
"openInNewWindow": "In neuem Fenster öffnen",
|
||||
"operation.contextCompression": "Kontext zu lang, komprimiere Verlauf...",
|
||||
"operation.execAgentRuntime": "Antwort wird vorbereitet",
|
||||
|
||||
@@ -17,10 +17,6 @@
|
||||
"workingDirectory.createBranchAction": "Neuen Zweig auschecken…",
|
||||
"workingDirectory.createBranchTitle": "Neuen Zweig erstellen",
|
||||
"workingDirectory.current": "Aktuelles Arbeitsverzeichnis",
|
||||
"workingDirectory.deleteBranchAction": "Branch löschen",
|
||||
"workingDirectory.deleteBranchConfirm": "Branch „{{name}}“ löschen? Dies entfernt ihn dauerhaft, einschließlich aller nicht zusammengeführten Commits.",
|
||||
"workingDirectory.deleteBranchTitle": "Branch löschen",
|
||||
"workingDirectory.deleteFailed": "Löschen fehlgeschlagen",
|
||||
"workingDirectory.detachedHead": "Losgelöster HEAD bei {{sha}}",
|
||||
"workingDirectory.diffStatTooltip": "Hinzugefügt {{added}} · Geändert {{modified}} · Gelöscht {{deleted}}",
|
||||
"workingDirectory.filesAdded": "Hinzugefügt",
|
||||
@@ -50,9 +46,6 @@
|
||||
"workingDirectory.recent": "Kürzlich",
|
||||
"workingDirectory.refreshGitStatus": "Zweig- und PR-Status aktualisieren",
|
||||
"workingDirectory.removeRecent": "Aus Kürzlich entfernen",
|
||||
"workingDirectory.renameBranchAction": "Branch umbenennen",
|
||||
"workingDirectory.renameBranchTitle": "Branch umbenennen",
|
||||
"workingDirectory.renameFailed": "Umbenennen fehlgeschlagen",
|
||||
"workingDirectory.selectFolder": "Ordner auswählen",
|
||||
"workingDirectory.title": "Arbeitsverzeichnis",
|
||||
"workingDirectory.topicDescription": "Überschreibt den Standard-Agenten nur für diese Unterhaltung",
|
||||
|
||||
@@ -94,9 +94,6 @@
|
||||
"pageEditor.deleteSuccess": "Seite erfolgreich gelöscht",
|
||||
"pageEditor.duplicateError": "Fehler beim Duplizieren der Seite",
|
||||
"pageEditor.duplicateSuccess": "Seite erfolgreich dupliziert",
|
||||
"pageEditor.editMode.checking": "Bearbeitsverfügbarkeit wird überprüft…",
|
||||
"pageEditor.editMode.lockedByOther": "{{name}} bearbeitet dieses Dokument",
|
||||
"pageEditor.editMode.lockedBySomeone": "Jemand anderes bearbeitet dieses Dokument",
|
||||
"pageEditor.editedAt": "Zuletzt bearbeitet am {{time}}",
|
||||
"pageEditor.editedBy": "Zuletzt bearbeitet von {{name}}",
|
||||
"pageEditor.editorPlaceholder": "Drücken Sie \"/\" für KI und Befehle",
|
||||
@@ -134,8 +131,6 @@
|
||||
"pageEditor.history.versionCount_one": "{{count}} Version",
|
||||
"pageEditor.history.versionCount_other": "{{count}} Versionen",
|
||||
"pageEditor.linkCopied": "Link kopiert",
|
||||
"pageEditor.lock.editingByOther": "{{name}} bearbeitet diese Seite. Ihre Änderungen können momentan nicht gespeichert werden.",
|
||||
"pageEditor.lock.editingBySomeone": "Jemand anderes bearbeitet diese Seite. Ihre Änderungen können momentan nicht gespeichert werden.",
|
||||
"pageEditor.menu.copyLink": "Link kopieren",
|
||||
"pageEditor.menu.export": "Exportieren",
|
||||
"pageEditor.menu.export.markdown": "Markdown",
|
||||
|
||||
@@ -307,8 +307,6 @@
|
||||
"providerModels.list.enabledActions.sort": "Benutzerdefinierte Modellsortierung",
|
||||
"providerModels.list.enabledEmpty": "Keine aktivierten Modelle verfügbar. Bitte aktivieren Sie Ihre bevorzugten Modelle aus der Liste unten~",
|
||||
"providerModels.list.fetcher.clear": "Abgerufene Modelle löschen",
|
||||
"providerModels.list.fetcher.error": "Fehler beim Abrufen der Modelle: {{message}}",
|
||||
"providerModels.list.fetcher.errorFallback": "Unbekannter Fehler",
|
||||
"providerModels.list.fetcher.fetch": "Modelle abrufen",
|
||||
"providerModels.list.fetcher.fetching": "Modellliste wird abgerufen...",
|
||||
"providerModels.list.fetcher.latestTime": "Zuletzt aktualisiert: {{time}}",
|
||||
|
||||
@@ -35,7 +35,6 @@
|
||||
"QuotaLimitReached": "Entschuldigung, die Token-Nutzung oder die Anzahl der Anfragen hat das Kontingentlimit für diesen Schlüssel erreicht. Bitte erhöhen Sie das Kontingent des Schlüssels oder versuchen Sie es später erneut.",
|
||||
"RateLimitExceeded": "Entschuldigung, die Token-Nutzung oder die Anzahl der Anfragen hat das Ratenlimit für diesen Schlüssel erreicht. Bitte versuchen Sie es später erneut oder erhöhen Sie das Kontingent des Schlüssels.",
|
||||
"StateStorePersistError": "Ein vorübergehendes Problem mit dem Konversationsstatusspeicher hat diesen Vorgang unterbrochen. Bitte versuchen Sie es erneut; falls das Problem weiterhin besteht, wenden Sie sich an den Support.",
|
||||
"StateStoreReadError": "Dieser Vorgang konnte nicht fortgesetzt werden, da der Sitzungsstatus nicht verfügbar war. Bitte öffnen Sie das Gespräch erneut, um fortzufahren; wenn das Problem weiterhin besteht, wenden Sie sich an den Support.",
|
||||
"StreamChunkError": "Fehler beim Parsen des Nachrichtenchunks der Streaming-Anfrage. Bitte überprüfen Sie, ob die aktuelle API-Schnittstelle den Standardspezifikationen entspricht, oder wenden Sie sich an Ihren API-Anbieter, um Unterstützung zu erhalten.",
|
||||
"UpstreamGatewayError": "Das Upstream-Gateway oder der Proxy hat einen Fehler zurückgegeben. Bitte versuchen Sie es in Kürze erneut; falls das Problem weiterhin besteht, überprüfen Sie Ihre Proxy-/Endpunktkonfiguration.",
|
||||
"UpstreamHttpError": "Der Anbieter hat einen HTTP-Fehler ohne weitere Details zurückgegeben. Bitte versuchen Sie es erneut oder überprüfen Sie Ihre Anfrage- und Modellkonfiguration.",
|
||||
|
||||
@@ -1,34 +0,0 @@
|
||||
{
|
||||
"generatingPhrases": [
|
||||
"Arbeiten",
|
||||
"Entwerfen",
|
||||
"Nachdenken",
|
||||
"Berechnen",
|
||||
"Brauen",
|
||||
"Synthesieren",
|
||||
"Knacken",
|
||||
"Gestalten",
|
||||
"Komponieren",
|
||||
"Orchestrieren",
|
||||
"Skizzieren",
|
||||
"Herumprobieren",
|
||||
"Überlegen",
|
||||
"Gestalten",
|
||||
"Flambieren",
|
||||
"Simmern",
|
||||
"Surren",
|
||||
"Zähmen",
|
||||
"Polieren",
|
||||
"Antwort vorbereiten",
|
||||
"Backen",
|
||||
"Kanalisieren",
|
||||
"Verschmelzen",
|
||||
"Entschlüsseln",
|
||||
"Schmieden",
|
||||
"Harmonisieren",
|
||||
"Improvisieren",
|
||||
"Schlussfolgern",
|
||||
"Tüfteln",
|
||||
"Zickzack bewegen"
|
||||
]
|
||||
}
|
||||
@@ -1186,9 +1186,6 @@
|
||||
"tools.klavis.notEnabled": "Klavis-Dienst nicht aktiviert",
|
||||
"tools.klavis.oauthRequired": "Bitte schließen Sie die OAuth-Authentifizierung im neuen Fenster ab",
|
||||
"tools.klavis.pendingAuth": "Authentifizierung ausstehend",
|
||||
"tools.klavis.remove": "Entfernen",
|
||||
"tools.klavis.removeConfirm.desc": "{{name}} wird dauerhaft aus Ihren verbundenen Diensten entfernt. Diese Aktion kann nicht rückgängig gemacht werden.",
|
||||
"tools.klavis.removeConfirm.title": "{{name}} entfernen?",
|
||||
"tools.klavis.serverCreated": "Server erfolgreich erstellt",
|
||||
"tools.klavis.serverCreatedFailed": "Servererstellung fehlgeschlagen",
|
||||
"tools.klavis.serverRemoved": "Server entfernt",
|
||||
|
||||
@@ -135,12 +135,6 @@
|
||||
"management.view.card": "Karte",
|
||||
"management.view.list": "Liste",
|
||||
"newTopic": "Neues Thema",
|
||||
"projectStatus.failed_one": "{{count}} fehlgeschlagenes Thema",
|
||||
"projectStatus.failed_other": "{{count}} fehlgeschlagene Themen",
|
||||
"projectStatus.loading_one": "{{count}} ladendes Thema",
|
||||
"projectStatus.loading_other": "{{count}} ladende Themen",
|
||||
"projectStatus.waitingForHuman_one": "{{count}} Thema wartet auf Eingabe",
|
||||
"projectStatus.waitingForHuman_other": "{{count}} Themen warten auf Eingabe",
|
||||
"renameModal.description": "Kurz und leicht erkennbar halten.",
|
||||
"renameModal.title": "Thema umbenennen",
|
||||
"searchPlaceholder": "Themen suchen...",
|
||||
|
||||
@@ -370,12 +370,12 @@
|
||||
"noMatchingAgents": "No matching members found",
|
||||
"noMembersYet": "This group doesn't have any members yet. Click the + button to invite agents.",
|
||||
"noSelectedAgents": "No members selected yet",
|
||||
"opStatusTray.cost": "cost",
|
||||
"opStatusTray.status.compressing": "Compressing context",
|
||||
"opStatusTray.status.generating": "Generating",
|
||||
"opStatusTray.status.reasoning": "Thinking",
|
||||
"opStatusTray.status.searching": "Searching",
|
||||
"opStatusTray.status.toolCalling": "Calling tools",
|
||||
"opStatusTray.cost": "cost",
|
||||
"opStatusTray.steps": "steps",
|
||||
"opStatusTray.tokens": "tokens",
|
||||
"openInNewWindow": "Open in New Window",
|
||||
|
||||
@@ -17,10 +17,6 @@
|
||||
"workingDirectory.createBranchAction": "Checkout new branch…",
|
||||
"workingDirectory.createBranchTitle": "Create new branch",
|
||||
"workingDirectory.current": "Current working directory",
|
||||
"workingDirectory.deleteBranchAction": "Delete branch",
|
||||
"workingDirectory.deleteBranchConfirm": "Delete branch “{{name}}”? This permanently removes it, including any unmerged commits.",
|
||||
"workingDirectory.deleteBranchTitle": "Delete branch",
|
||||
"workingDirectory.deleteFailed": "Delete failed",
|
||||
"workingDirectory.detachedHead": "Detached HEAD at {{sha}}",
|
||||
"workingDirectory.diffStatTooltip": "Added {{added}} · Modified {{modified}} · Deleted {{deleted}}",
|
||||
"workingDirectory.filesAdded": "Added",
|
||||
@@ -50,9 +46,6 @@
|
||||
"workingDirectory.recent": "Recent",
|
||||
"workingDirectory.refreshGitStatus": "Refresh branch & PR status",
|
||||
"workingDirectory.removeRecent": "Remove from recent",
|
||||
"workingDirectory.renameBranchAction": "Rename branch",
|
||||
"workingDirectory.renameBranchTitle": "Rename branch",
|
||||
"workingDirectory.renameFailed": "Rename failed",
|
||||
"workingDirectory.selectFolder": "Select folder",
|
||||
"workingDirectory.title": "Working Directory",
|
||||
"workingDirectory.topicDescription": "Override Agent default for this conversation only",
|
||||
|
||||
@@ -94,9 +94,6 @@
|
||||
"pageEditor.deleteSuccess": "Page deleted successfully",
|
||||
"pageEditor.duplicateError": "Failed to duplicate the page",
|
||||
"pageEditor.duplicateSuccess": "Page duplicated successfully",
|
||||
"pageEditor.editMode.checking": "Checking edit availability…",
|
||||
"pageEditor.editMode.lockedByOther": "{{name}} is editing this document",
|
||||
"pageEditor.editMode.lockedBySomeone": "Someone else is editing this document",
|
||||
"pageEditor.editedAt": "Last edited on {{time}}",
|
||||
"pageEditor.editedBy": "Last edited by {{name}}",
|
||||
"pageEditor.editorPlaceholder": "Press \"/\" for AI and commands.",
|
||||
@@ -134,8 +131,6 @@
|
||||
"pageEditor.history.versionCount_one": "{{count}} version",
|
||||
"pageEditor.history.versionCount_other": "{{count}} versions",
|
||||
"pageEditor.linkCopied": "Link copied",
|
||||
"pageEditor.lock.editingByOther": "{{name}} is editing this page. Your changes can’t be saved right now.",
|
||||
"pageEditor.lock.editingBySomeone": "Someone else is editing this page. Your changes can’t be saved right now.",
|
||||
"pageEditor.menu.copyLink": "Copy Link",
|
||||
"pageEditor.menu.export": "Export",
|
||||
"pageEditor.menu.export.markdown": "Markdown",
|
||||
|
||||
@@ -307,8 +307,6 @@
|
||||
"providerModels.list.enabledActions.sort": "Custom Model Sorting",
|
||||
"providerModels.list.enabledEmpty": "No enabled models available. Please enable your preferred models from the list below~",
|
||||
"providerModels.list.fetcher.clear": "Clear fetched models",
|
||||
"providerModels.list.fetcher.error": "Failed to fetch models: {{message}}",
|
||||
"providerModels.list.fetcher.errorFallback": "Unknown error",
|
||||
"providerModels.list.fetcher.fetch": "Fetch models",
|
||||
"providerModels.list.fetcher.fetching": "Fetching model list...",
|
||||
"providerModels.list.fetcher.latestTime": "Last updated: {{time}}",
|
||||
|
||||
@@ -35,7 +35,6 @@
|
||||
"QuotaLimitReached": "Sorry, the token usage or request count has reached the quota limit for this key. Please increase the key's quota or try again later.",
|
||||
"RateLimitExceeded": "Sorry, the token usage or request count has reached the rate limit for this key. Please try again later or increase the key's quota.",
|
||||
"StateStorePersistError": "A temporary issue with the conversation state store interrupted this operation. Please try again; if it persists, contact support.",
|
||||
"StateStoreReadError": "This operation could not be resumed because its session state was unavailable. Please reopen the conversation to continue; if it persists, contact support.",
|
||||
"StreamChunkError": "Error parsing the message chunk of the streaming request. Please check if the current API interface complies with the standard specifications, or contact your API provider for assistance.",
|
||||
"UpstreamGatewayError": "The upstream gateway or proxy returned an error. Please try again shortly; if it persists, check your proxy / endpoint configuration.",
|
||||
"UpstreamHttpError": "The provider returned an HTTP error without further detail. Please try again, or check your request and model configuration.",
|
||||
|
||||
@@ -1179,6 +1179,9 @@
|
||||
"tools.klavis.disconnect": "Disconnect",
|
||||
"tools.klavis.disconnected": "Disconnected",
|
||||
"tools.klavis.error": "Error",
|
||||
"tools.klavis.remove": "Remove",
|
||||
"tools.klavis.removeConfirm.desc": "{{name}} will be permanently removed from your connected services. This action cannot be undone.",
|
||||
"tools.klavis.removeConfirm.title": "Remove {{name}}?",
|
||||
"tools.klavis.groupName": "Klavis Tools",
|
||||
"tools.klavis.manage": "Manage Klavis",
|
||||
"tools.klavis.manageTitle": "Manage Klavis Integration",
|
||||
@@ -1186,9 +1189,6 @@
|
||||
"tools.klavis.notEnabled": "Klavis service not enabled",
|
||||
"tools.klavis.oauthRequired": "Please complete OAuth authentication in the new window",
|
||||
"tools.klavis.pendingAuth": "Pending Authentication",
|
||||
"tools.klavis.remove": "Remove",
|
||||
"tools.klavis.removeConfirm.desc": "{{name}} will be permanently removed from your connected services. This action cannot be undone.",
|
||||
"tools.klavis.removeConfirm.title": "Remove {{name}}?",
|
||||
"tools.klavis.serverCreated": "Server created successfully",
|
||||
"tools.klavis.serverCreatedFailed": "Failed to create server",
|
||||
"tools.klavis.serverRemoved": "Server removed",
|
||||
|
||||
@@ -370,14 +370,6 @@
|
||||
"noMatchingAgents": "No se encontraron miembros coincidentes",
|
||||
"noMembersYet": "Este grupo aún no tiene miembros. Haz clic en el botón + para invitar agentes.",
|
||||
"noSelectedAgents": "Aún no se han seleccionado miembros",
|
||||
"opStatusTray.cost": "costo",
|
||||
"opStatusTray.status.compressing": "Comprimiendo contexto",
|
||||
"opStatusTray.status.generating": "Generando",
|
||||
"opStatusTray.status.reasoning": "Pensando",
|
||||
"opStatusTray.status.searching": "Buscando",
|
||||
"opStatusTray.status.toolCalling": "Llamando herramientas",
|
||||
"opStatusTray.steps": "pasos",
|
||||
"opStatusTray.tokens": "fichas",
|
||||
"openInNewWindow": "Abrir en una nueva ventana",
|
||||
"operation.contextCompression": "Contexto demasiado largo, comprimiendo historial...",
|
||||
"operation.execAgentRuntime": "Preparando respuesta",
|
||||
|
||||
@@ -17,10 +17,6 @@
|
||||
"workingDirectory.createBranchAction": "Cambiar a nueva rama…",
|
||||
"workingDirectory.createBranchTitle": "Crear nueva rama",
|
||||
"workingDirectory.current": "Directorio de trabajo actual",
|
||||
"workingDirectory.deleteBranchAction": "Eliminar rama",
|
||||
"workingDirectory.deleteBranchConfirm": "¿Eliminar la rama “{{name}}”? Esto la eliminará permanentemente, incluyendo cualquier commit no fusionado.",
|
||||
"workingDirectory.deleteBranchTitle": "Eliminar rama",
|
||||
"workingDirectory.deleteFailed": "Error al eliminar",
|
||||
"workingDirectory.detachedHead": "HEAD separado en {{sha}}",
|
||||
"workingDirectory.diffStatTooltip": "Añadido {{added}} · Modificado {{modified}} · Eliminado {{deleted}}",
|
||||
"workingDirectory.filesAdded": "Añadido",
|
||||
@@ -50,9 +46,6 @@
|
||||
"workingDirectory.recent": "Recientes",
|
||||
"workingDirectory.refreshGitStatus": "Actualizar estado de rama y PR",
|
||||
"workingDirectory.removeRecent": "Eliminar de recientes",
|
||||
"workingDirectory.renameBranchAction": "Renombrar rama",
|
||||
"workingDirectory.renameBranchTitle": "Renombrar rama",
|
||||
"workingDirectory.renameFailed": "Error al renombrar",
|
||||
"workingDirectory.selectFolder": "Seleccionar carpeta",
|
||||
"workingDirectory.title": "Directorio de Trabajo",
|
||||
"workingDirectory.topicDescription": "Sobrescribir el valor predeterminado del Agente solo para esta conversación",
|
||||
|
||||
@@ -94,9 +94,6 @@
|
||||
"pageEditor.deleteSuccess": "Página eliminada correctamente",
|
||||
"pageEditor.duplicateError": "Error al duplicar la página",
|
||||
"pageEditor.duplicateSuccess": "Página duplicada correctamente",
|
||||
"pageEditor.editMode.checking": "Comprobando la disponibilidad de edición…",
|
||||
"pageEditor.editMode.lockedByOther": "{{name}} está editando este documento",
|
||||
"pageEditor.editMode.lockedBySomeone": "Alguien más está editando este documento",
|
||||
"pageEditor.editedAt": "Última edición el {{time}}",
|
||||
"pageEditor.editedBy": "Última edición por {{name}}",
|
||||
"pageEditor.editorPlaceholder": "Presiona \"/\" para IA y comandos",
|
||||
@@ -134,8 +131,6 @@
|
||||
"pageEditor.history.versionCount_one": "{{count}} versión",
|
||||
"pageEditor.history.versionCount_other": "{{count}} versiones",
|
||||
"pageEditor.linkCopied": "Enlace copiado",
|
||||
"pageEditor.lock.editingByOther": "{{name}} está editando esta página. Tus cambios no se pueden guardar en este momento.",
|
||||
"pageEditor.lock.editingBySomeone": "Alguien más está editando esta página. Tus cambios no se pueden guardar en este momento.",
|
||||
"pageEditor.menu.copyLink": "Copiar enlace",
|
||||
"pageEditor.menu.export": "Exportar",
|
||||
"pageEditor.menu.export.markdown": "Markdown",
|
||||
|
||||
@@ -307,8 +307,6 @@
|
||||
"providerModels.list.enabledActions.sort": "Orden personalizado de modelos",
|
||||
"providerModels.list.enabledEmpty": "No hay modelos habilitados disponibles. Habilita tus modelos preferidos de la lista a continuación~",
|
||||
"providerModels.list.fetcher.clear": "Borrar modelos obtenidos",
|
||||
"providerModels.list.fetcher.error": "Error al obtener los modelos: {{message}}",
|
||||
"providerModels.list.fetcher.errorFallback": "Error desconocido",
|
||||
"providerModels.list.fetcher.fetch": "Obtener modelos",
|
||||
"providerModels.list.fetcher.fetching": "Obteniendo lista de modelos...",
|
||||
"providerModels.list.fetcher.latestTime": "Última actualización: {{time}}",
|
||||
|
||||
@@ -35,7 +35,6 @@
|
||||
"QuotaLimitReached": "Lo sentimos, el uso de tokens o el número de solicitudes ha alcanzado el límite de cuota para esta clave. Por favor, aumenta la cuota de la clave o vuelve a intentarlo más tarde.",
|
||||
"RateLimitExceeded": "Lo sentimos, el uso de tokens o el número de solicitudes ha alcanzado el límite de velocidad para esta clave. Por favor, vuelve a intentarlo más tarde o aumenta la cuota de la clave.",
|
||||
"StateStorePersistError": "Un problema temporal con el almacenamiento del estado de la conversación interrumpió esta operación. Por favor, vuelve a intentarlo; si el problema persiste, contacta al soporte.",
|
||||
"StateStoreReadError": "Esta operación no se pudo reanudar porque el estado de su sesión no estaba disponible. Por favor, vuelva a abrir la conversación para continuar; si el problema persiste, contacte con el soporte.",
|
||||
"StreamChunkError": "Error al analizar el fragmento de mensaje de la solicitud de transmisión. Por favor, verifica si la interfaz API actual cumple con las especificaciones estándar, o contacta a tu proveedor de API para obtener ayuda.",
|
||||
"UpstreamGatewayError": "El gateway o proxy del proveedor devolvió un error. Por favor, vuelve a intentarlo en breve; si el problema persiste, verifica tu configuración de proxy/punto final.",
|
||||
"UpstreamHttpError": "El proveedor devolvió un error HTTP sin más detalles. Por favor, vuelve a intentarlo, o verifica tu solicitud y configuración del modelo.",
|
||||
|
||||
@@ -1,34 +0,0 @@
|
||||
{
|
||||
"generatingPhrases": [
|
||||
"Trabajando",
|
||||
"Redactando",
|
||||
"Pensando",
|
||||
"Calculando",
|
||||
"Preparando",
|
||||
"Sintetizando",
|
||||
"Procesando",
|
||||
"Arquitectando",
|
||||
"Componiendo",
|
||||
"Orquestando",
|
||||
"Esbozando",
|
||||
"Improvisando",
|
||||
"Reflexionando",
|
||||
"Elaborando",
|
||||
"Flambeando",
|
||||
"Cocinando a fuego lento",
|
||||
"Zumbando",
|
||||
"Domando",
|
||||
"Puliendo",
|
||||
"Preparando la respuesta",
|
||||
"Horneando",
|
||||
"Canalizando",
|
||||
"Fusionando",
|
||||
"Descifrando",
|
||||
"Forjando",
|
||||
"Armonizando",
|
||||
"Improvisando",
|
||||
"Deduciendo",
|
||||
"Trasteando",
|
||||
"Zigzagueando"
|
||||
]
|
||||
}
|
||||
@@ -1186,9 +1186,6 @@
|
||||
"tools.klavis.notEnabled": "Servicio Klavis no habilitado",
|
||||
"tools.klavis.oauthRequired": "Por favor, completa la autenticación OAuth en la nueva ventana",
|
||||
"tools.klavis.pendingAuth": "Autenticación Pendiente",
|
||||
"tools.klavis.remove": "Eliminar",
|
||||
"tools.klavis.removeConfirm.desc": "{{name}} se eliminará permanentemente de tus servicios conectados. Esta acción no se puede deshacer.",
|
||||
"tools.klavis.removeConfirm.title": "¿Eliminar {{name}}?",
|
||||
"tools.klavis.serverCreated": "Servidor creado con éxito",
|
||||
"tools.klavis.serverCreatedFailed": "Error al crear el servidor",
|
||||
"tools.klavis.serverRemoved": "Servidor eliminado",
|
||||
|
||||
@@ -135,12 +135,6 @@
|
||||
"management.view.card": "Tarjeta",
|
||||
"management.view.list": "Lista",
|
||||
"newTopic": "Nuevo tema",
|
||||
"projectStatus.failed_one": "{{count}} tema fallido",
|
||||
"projectStatus.failed_other": "{{count}} temas fallidos",
|
||||
"projectStatus.loading_one": "{{count}} tema cargando",
|
||||
"projectStatus.loading_other": "{{count}} temas cargando",
|
||||
"projectStatus.waitingForHuman_one": "{{count}} tema esperando entrada",
|
||||
"projectStatus.waitingForHuman_other": "{{count}} temas esperando entrada",
|
||||
"renameModal.description": "Mantenlo breve y fácil de reconocer.",
|
||||
"renameModal.title": "Renombrar tema",
|
||||
"searchPlaceholder": "Buscar temas...",
|
||||
|
||||
@@ -370,14 +370,6 @@
|
||||
"noMatchingAgents": "هیچ عضوی مطابق یافت نشد",
|
||||
"noMembersYet": "این گروه هنوز عضوی ندارد. برای دعوت نمایندهها روی دکمه + کلیک کنید.",
|
||||
"noSelectedAgents": "هنوز عضوی انتخاب نشده است",
|
||||
"opStatusTray.cost": "هزینه",
|
||||
"opStatusTray.status.compressing": "فشردهسازی محتوا",
|
||||
"opStatusTray.status.generating": "تولید",
|
||||
"opStatusTray.status.reasoning": "تفکر",
|
||||
"opStatusTray.status.searching": "جستجو",
|
||||
"opStatusTray.status.toolCalling": "فراخوانی ابزارها",
|
||||
"opStatusTray.steps": "مراحل",
|
||||
"opStatusTray.tokens": "توکنها",
|
||||
"openInNewWindow": "باز کردن در پنجره جدید",
|
||||
"operation.contextCompression": "متن بیش از حد طولانی است، در حال فشردهسازی تاریخچه...",
|
||||
"operation.execAgentRuntime": "در حال آمادهسازی پاسخ",
|
||||
|
||||
@@ -17,10 +17,6 @@
|
||||
"workingDirectory.createBranchAction": "ایجاد شاخه جدید…",
|
||||
"workingDirectory.createBranchTitle": "ایجاد شاخه جدید",
|
||||
"workingDirectory.current": "دایرکتوری کاری فعلی",
|
||||
"workingDirectory.deleteBranchAction": "حذف شاخه",
|
||||
"workingDirectory.deleteBranchConfirm": "آیا میخواهید شاخه «{{name}}» را حذف کنید؟ این عمل به طور دائمی آن را حذف میکند، شامل هرگونه کامیت ادغامنشده.",
|
||||
"workingDirectory.deleteBranchTitle": "حذف شاخه",
|
||||
"workingDirectory.deleteFailed": "حذف ناموفق بود",
|
||||
"workingDirectory.detachedHead": "HEAD جدا شده در {{sha}}",
|
||||
"workingDirectory.diffStatTooltip": "افزوده شده {{added}} · تغییر یافته {{modified}} · حذف شده {{deleted}}",
|
||||
"workingDirectory.filesAdded": "افزوده شده",
|
||||
@@ -50,9 +46,6 @@
|
||||
"workingDirectory.recent": "اخیر",
|
||||
"workingDirectory.refreshGitStatus": "وضعیت شاخه و درخواستها را تازهسازی کنید",
|
||||
"workingDirectory.removeRecent": "حذف از موارد اخیر",
|
||||
"workingDirectory.renameBranchAction": "تغییر نام شاخه",
|
||||
"workingDirectory.renameBranchTitle": "تغییر نام شاخه",
|
||||
"workingDirectory.renameFailed": "تغییر نام ناموفق بود",
|
||||
"workingDirectory.selectFolder": "انتخاب پوشه",
|
||||
"workingDirectory.title": "دایرکتوری کاری",
|
||||
"workingDirectory.topicDescription": "جایگزینی پیشفرض عامل فقط برای این مکالمه",
|
||||
|
||||
@@ -94,9 +94,6 @@
|
||||
"pageEditor.deleteSuccess": "صفحه با موفقیت حذف شد",
|
||||
"pageEditor.duplicateError": "تکثیر صفحه ناموفق بود",
|
||||
"pageEditor.duplicateSuccess": "صفحه با موفقیت تکثیر شد",
|
||||
"pageEditor.editMode.checking": "در حال بررسی امکان ویرایش...",
|
||||
"pageEditor.editMode.lockedByOther": "{{name}} در حال ویرایش این سند است",
|
||||
"pageEditor.editMode.lockedBySomeone": "شخص دیگری در حال ویرایش این سند است",
|
||||
"pageEditor.editedAt": "آخرین ویرایش در {{time}}",
|
||||
"pageEditor.editedBy": "آخرین ویرایش توسط {{name}}",
|
||||
"pageEditor.editorPlaceholder": "برای هوش مصنوعی و دستورات \"/\" را فشار دهید",
|
||||
@@ -134,8 +131,6 @@
|
||||
"pageEditor.history.versionCount_one": "{{count}} نسخه",
|
||||
"pageEditor.history.versionCount_other": "{{count}} نسخه",
|
||||
"pageEditor.linkCopied": "لینک کپی شد",
|
||||
"pageEditor.lock.editingByOther": "{{name}} در حال ویرایش این صفحه است. تغییرات شما در حال حاضر ذخیره نمیشود.",
|
||||
"pageEditor.lock.editingBySomeone": "شخص دیگری در حال ویرایش این صفحه است. تغییرات شما در حال حاضر ذخیره نمیشود.",
|
||||
"pageEditor.menu.copyLink": "کپی لینک",
|
||||
"pageEditor.menu.export": "صادرات",
|
||||
"pageEditor.menu.export.markdown": "Markdown",
|
||||
|
||||
@@ -307,8 +307,6 @@
|
||||
"providerModels.list.enabledActions.sort": "مرتبسازی مدلهای سفارشی",
|
||||
"providerModels.list.enabledEmpty": "هیچ مدل فعالی در دسترس نیست. لطفاً مدلهای دلخواه خود را از لیست زیر فعال کنید~",
|
||||
"providerModels.list.fetcher.clear": "پاکسازی مدلهای دریافتشده",
|
||||
"providerModels.list.fetcher.error": "دریافت مدلها با شکست مواجه شد: {{message}}",
|
||||
"providerModels.list.fetcher.errorFallback": "خطای ناشناخته",
|
||||
"providerModels.list.fetcher.fetch": "دریافت مدلها",
|
||||
"providerModels.list.fetcher.fetching": "در حال دریافت لیست مدلها...",
|
||||
"providerModels.list.fetcher.latestTime": "آخرین بهروزرسانی: {{time}}",
|
||||
|
||||
@@ -35,7 +35,6 @@
|
||||
"QuotaLimitReached": "متأسفیم، استفاده از توکن یا تعداد درخواستها به حد سهمیه این کلید رسیده است. لطفاً سهمیه کلید را افزایش داده یا بعداً دوباره تلاش کنید.",
|
||||
"RateLimitExceeded": "متأسفیم، استفاده از توکن یا تعداد درخواستها به حد نرخ این کلید رسیده است. لطفاً بعداً دوباره تلاش کنید یا سهمیه کلید را افزایش دهید.",
|
||||
"StateStorePersistError": "یک مشکل موقت در ذخیرهسازی وضعیت مکالمه این عملیات را مختل کرد. لطفاً دوباره تلاش کنید؛ اگر مشکل ادامه داشت، با پشتیبانی تماس بگیرید.",
|
||||
"StateStoreReadError": "این عملیات نمیتواند ادامه یابد زیرا وضعیت جلسه در دسترس نیست. لطفاً مکالمه را دوباره باز کنید تا ادامه دهید؛ اگر مشکل ادامه داشت، با پشتیبانی تماس بگیرید.",
|
||||
"StreamChunkError": "خطا در تجزیه بخش پیام درخواست جریان. لطفاً بررسی کنید که آیا رابط API فعلی با مشخصات استاندارد مطابقت دارد یا با ارائهدهنده API خود تماس بگیرید.",
|
||||
"UpstreamGatewayError": "دروازه یا پراکسی بالادستی خطایی بازگرداند. لطفاً به زودی دوباره تلاش کنید؛ اگر مشکل ادامه داشت، پیکربندی پراکسی/نقطه پایانی خود را بررسی کنید.",
|
||||
"UpstreamHttpError": "ارائهدهنده یک خطای HTTP بدون جزئیات بیشتر بازگرداند. لطفاً دوباره تلاش کنید یا درخواست و پیکربندی مدل خود را بررسی کنید.",
|
||||
|
||||
@@ -1,34 +0,0 @@
|
||||
{
|
||||
"generatingPhrases": [
|
||||
"در حال کار",
|
||||
"در حال پیشنویس",
|
||||
"در حال تفکر",
|
||||
"در حال محاسبه",
|
||||
"در حال دم کردن",
|
||||
"در حال ترکیب",
|
||||
"در حال پردازش",
|
||||
"در حال معماری",
|
||||
"در حال ترکیببندی",
|
||||
"در حال هماهنگی",
|
||||
"در حال طراحی",
|
||||
"در حال آزمودن",
|
||||
"در حال اندیشیدن",
|
||||
"در حال ساختن",
|
||||
"در حال شعلهور کردن",
|
||||
"در حال جوشاندن",
|
||||
"در حال چرخیدن",
|
||||
"در حال مدیریت",
|
||||
"در حال پرداخت",
|
||||
"در حال آمادهسازی پاسخ",
|
||||
"در حال پختن",
|
||||
"در حال هدایت",
|
||||
"در حال همگرایی",
|
||||
"در حال رمزگشایی",
|
||||
"در حال شکلدهی",
|
||||
"در حال هماهنگسازی",
|
||||
"در حال بداههپردازی",
|
||||
"در حال استنتاج",
|
||||
"در حال دستکاری",
|
||||
"در حال زیگزاگ رفتن"
|
||||
]
|
||||
}
|
||||
@@ -1186,9 +1186,6 @@
|
||||
"tools.klavis.notEnabled": "سرویس Klavis فعال نیست",
|
||||
"tools.klavis.oauthRequired": "لطفاً احراز هویت OAuth را در پنجره جدید کامل کنید",
|
||||
"tools.klavis.pendingAuth": "در انتظار احراز هویت",
|
||||
"tools.klavis.remove": "حذف",
|
||||
"tools.klavis.removeConfirm.desc": "{{name}} به طور دائمی از خدمات متصل شما حذف خواهد شد. این اقدام قابل بازگشت نیست.",
|
||||
"tools.klavis.removeConfirm.title": "حذف {{name}}؟",
|
||||
"tools.klavis.serverCreated": "سرور با موفقیت ایجاد شد",
|
||||
"tools.klavis.serverCreatedFailed": "ایجاد سرور ناموفق بود",
|
||||
"tools.klavis.serverRemoved": "سرور حذف شد",
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user