first commit
This commit is contained in:
commit
71dd330bdb
|
|
@ -0,0 +1,77 @@
|
|||
---
|
||||
name: composio
|
||||
description: Route and complete Composio work across Composio For You and Composio Platform. Use when the user mentions Composio; wants an agent to use apps such as Gmail, Slack, GitHub, Notion, Calendar, or Linear; needs first-time setup, an SDK or MCP integration, CLI operation, migration guidance, current documentation, or help diagnosing a connection or tool call.
|
||||
---
|
||||
|
||||
# Composio
|
||||
|
||||
Use this skill as a router. Identify the product and the job, load only the relevant guidance, consult canonical documentation for volatile details, and then answer or do the work the user requested.
|
||||
|
||||
## 1. Choose the product
|
||||
|
||||
Do not blend the products. They use different credentials and setup paths.
|
||||
|
||||
| | Composio For You | Composio Platform |
|
||||
|---|---|---|
|
||||
| Use when | Someone wants their own agent to use their own apps | A developer is building a product whose users connect accounts |
|
||||
| Primary surface | MCP or the Composio CLI | SDK sessions inside an application |
|
||||
| Credential | `ck_...` consumer key when the client requires a header | `COMPOSIO_API_KEY` project key |
|
||||
| Dashboard | `dashboard.composio.dev` → For You | `dashboard.composio.dev` → Platform |
|
||||
|
||||
Ask one short question only when context does not establish the product:
|
||||
|
||||
> Is this for your own agent and accounts, or for a product where your users connect their accounts?
|
||||
|
||||
Treat a named personal AI client with no product code as For You. Treat an application codebase, SDK, user or tenant identity, backend, or product agent as Platform.
|
||||
|
||||
## 2. Choose the job
|
||||
|
||||
Identify the requested outcome before taking action:
|
||||
|
||||
- **Explain or discover:** answer a question, compare approaches, or find the current API.
|
||||
- **Set up:** establish credentials, an MCP client, the CLI, or an SDK for the first time.
|
||||
- **Build or change:** integrate Composio into an existing agent or application.
|
||||
- **Operate:** find, connect, and run tools for a real task.
|
||||
- **Debug or migrate:** diagnose a failure, update an older integration, or move from legacy direct execution or Tool Router.
|
||||
|
||||
Do not turn an explanation, documentation lookup, or narrow bug fix into onboarding.
|
||||
|
||||
## 3. Load only the relevant guidance
|
||||
|
||||
- For You: read [Composio For You](references/for-you.md).
|
||||
- Platform: read [Composio Platform](references/platform.md).
|
||||
- Provider, connection, or execution failure: also read [Errors and provider gotchas](references/errors.md).
|
||||
|
||||
## Complete the selected job
|
||||
|
||||
- For a question, fetch current documentation when needed and give the concrete answer. Do not mutate a project or force a tool call.
|
||||
- For setup or integration, inspect the existing environment, preserve its architecture and identity model, make the smallest useful change, and verify it with one safe real tool call when credentials and user authorization are available.
|
||||
- For an operational request, connect only the apps the task needs and execute the requested workflow.
|
||||
- For debugging, get the Composio log or request ID, identify the failing boundary, fix that boundary, and retry when the user authorized execution.
|
||||
|
||||
## Stable rules
|
||||
|
||||
1. Establish the product before choosing credentials, URLs, SDKs, or commands.
|
||||
2. Treat dashboard onboarding as a context, not the skill's identity. When the developer arrives with an existing `COMPOSIO_API_KEY` from Getting Started, use it and never create, rotate, replace, print, or request it in chat. Do not run `composio dev init` in that path.
|
||||
3. For a general first-time Platform setup with no dashboard credential handoff, follow the current setup path in the Platform guide.
|
||||
4. Never invent toolkit or tool slugs. Discover them at runtime or with the CLI.
|
||||
5. Do not build a provider OAuth flow. Composio returns a Connect Link when authentication is needed.
|
||||
6. Use sessions for new Platform integrations. Preserve the application's existing user identity and agent architecture.
|
||||
7. Keep credentials out of source control, URLs, logs, chat, and command output.
|
||||
8. Get the log or request ID before diagnosing a failed tool call.
|
||||
9. Prefer the smallest configuration that completes the current job. Keep toolkit filters, tag policies, sandbox controls, custom auth, provider-specific hardening, and other advanced options out of the first path unless the request or existing code requires them.
|
||||
10. Do not invent repository facts. Never claim that a file, framework, environment loader, identity field, agent path, or dependency exists until it was provided or inspected. If codebase context is unavailable, state the unknown and ask for access or one necessary detail.
|
||||
|
||||
## Canonical information
|
||||
|
||||
Use bundled references for stable decisions. For versions, provider adapters, client-specific setup, toolkit behavior, or APIs that may have changed, fetch the current Markdown source before answering or editing code:
|
||||
|
||||
When sources disagree, prefer the current API reference and live endpoint behavior over any page marked Legacy, and name the REST API version explicitly.
|
||||
|
||||
```text
|
||||
https://docs.composio.dev/llms.txt
|
||||
https://docs.composio.dev/docs/<page>.md
|
||||
https://docs.composio.dev/toolkits/<toolkit>.md
|
||||
```
|
||||
|
||||
Use the documentation to complete the task. Do not merely hand the user a link unless they asked for one.
|
||||
|
|
@ -0,0 +1,70 @@
|
|||
# Errors and provider gotchas
|
||||
|
||||
Use this guide for failures shared by Composio For You and Composio Platform. Keep product-specific credential and client setup in the selected product guide.
|
||||
|
||||
## Start with evidence
|
||||
|
||||
Get the Composio log or request ID and inspect Dashboard Logs before changing credentials or code. Agent frameworks often wrap the underlying provider error.
|
||||
|
||||
When the CLI is already installed and authenticated for the applicable product, these commands can provide additional evidence:
|
||||
|
||||
```bash
|
||||
composio dev logs tools
|
||||
composio dev logs triggers
|
||||
composio connections list
|
||||
```
|
||||
|
||||
Do not install or reinitialize the CLI solely to diagnose a dashboard log that already contains the failure.
|
||||
|
||||
## Tool does not exist
|
||||
|
||||
Never guess a slug. In a Platform session, discover tools through the session meta tools. In a CLI workflow, use `composio search`, then inspect the returned tool.
|
||||
|
||||
For legacy manual execution, a missing tool may be a toolkit-version problem. Fetch current migration and execution docs rather than assuming the provider lacks the operation. New session integrations should use runtime discovery.
|
||||
|
||||
## Identify the authentication boundary
|
||||
|
||||
### Composio project or session 401
|
||||
|
||||
This happens before a provider tool call succeeds. The Platform project credential may be missing, masked, invalid, or associated with a different project.
|
||||
|
||||
Re-run the no-output credential checks from the Platform guide. Do not print, rotate, replace, or request the key in chat. If the developer arrived from Dashboard Getting Started, direct them back to that project's Step 1 rather than running `composio dev init`.
|
||||
|
||||
### Provider connected-account 401
|
||||
|
||||
This appears on a real tool execution after the project and session reached the provider. The selected user's provider token may be revoked, expired, or invalidated by a password, 2FA, consent, or administrator-policy change.
|
||||
|
||||
Keep the same project key and application user ID. Generate a fresh Connect Link for that integration, reconnect the provider account, and retry the safe call. If a link expired, request a new one.
|
||||
|
||||
### For You client authentication
|
||||
|
||||
If the MCP client itself cannot authenticate, verify the consumer endpoint, OAuth session, or `ck_...` header path from the For You guide. Do not substitute a Platform project key.
|
||||
|
||||
## Common provider constraints
|
||||
|
||||
- **Google "App is blocked":** remove unnecessary scopes or use a verified custom OAuth app.
|
||||
- **Google API disabled:** enable the required provider API in the Google Cloud project that owns the custom credentials.
|
||||
- **Slack 429:** managed apps share provider quota; use a custom Slack app for a dedicated bucket when needed.
|
||||
- **Microsoft 403:** the tenant may require administrator consent.
|
||||
- **GitHub App access:** OAuth credentials and repository installation are separate steps.
|
||||
- **Payment toolkit session restriction:** treat it as a surface policy restriction, not a plan or connection failure.
|
||||
|
||||
## Branding and production auth
|
||||
|
||||
Managed auth is intended to make initial development easy. Before launch, move integrations that require the application's branding, scopes, or dedicated quotas onto its own OAuth apps.
|
||||
|
||||
When someone asks to remove Composio branding, identify the surface first: Connect Link page, provider consent screen, secured badge, callback domain, or success page. They have different fixes. Fetch `white-labeling-authentication.md` before proposing an implementation.
|
||||
|
||||
## Triggers and webhooks
|
||||
|
||||
Check the Composio status page and trigger logs before changing a trigger. Use current trigger documentation for event names, polling limits, and connection-state verification. Do not promise static outbound IPs; use documented webhook signature verification.
|
||||
|
||||
## Canonical follow-up
|
||||
|
||||
For provider- or toolkit-specific behavior, fetch:
|
||||
|
||||
```text
|
||||
https://docs.composio.dev/toolkits/<toolkit>.md
|
||||
```
|
||||
|
||||
For APIs, migrations, triggers, or compliance questions, find the current page through `https://docs.composio.dev/llms.txt`. If the problem remains unresolved, include the log ID when escalating to Composio support.
|
||||
|
|
@ -0,0 +1,80 @@
|
|||
# Composio For You
|
||||
|
||||
Use this product when someone wants their own AI client or terminal agent to use their own connected apps. Route by task: answer a question, configure an MCP client, operate through the CLI, connect an app, or debug an existing setup.
|
||||
|
||||
## Stable product contract
|
||||
|
||||
- MCP endpoint: `https://connect.composio.dev/mcp`
|
||||
- Consumer key when a client needs a header: `ck_...`
|
||||
- Header name: `x-consumer-api-key`
|
||||
- Key location: Dashboard → For You → AI Clients → select the client
|
||||
|
||||
The removed `mcp.composio.dev` endpoint and Platform MCP URLs are not substitutes. The `ck_...` key and Platform's `COMPOSIO_API_KEY` are not interchangeable.
|
||||
|
||||
## Choose MCP or CLI
|
||||
|
||||
Default to MCP for desktop and hosted AI clients. Use the CLI for terminal agents that can execute commands and operate tools directly.
|
||||
|
||||
### Know what each surface installs
|
||||
|
||||
These surfaces are complementary. Installing one does not automatically install the others.
|
||||
|
||||
| Surface | What the agent discovers |
|
||||
|---|---|
|
||||
| Raw Composio MCP connection | Callable tools and their schemas. MCP does not install an agent skill. |
|
||||
| Public `composio` skill | This product-and-job router. Install it explicitly from `ComposioHQ/composio`. |
|
||||
| Composio CLI | The `composio` command plus the separately maintained `composio-cli` skill when the CLI's agent setup flow installs it. That skill owns current command usage. |
|
||||
| OpenAI/Codex plugin | The hosted Composio app plus its bundled `composio-runtime` skill, which selects between hosted tools and the local CLI. |
|
||||
| Claude Code plugin | Commands and hooks. It does not bundle a skill; CLI login/setup installs `composio-cli` separately. |
|
||||
|
||||
An agent can therefore see Composio tools without seeing either Composio skill, and it can have more than one skill when their roles differ. Use `composio` for product selection and integration guidance, `composio-runtime` for OpenAI plugin routing, and `composio-cli` for CLI operation.
|
||||
|
||||
### MCP clients
|
||||
|
||||
Claude Desktop and ChatGPT use browser OAuth and do not need a consumer key. Header-based clients use the endpoint and header above.
|
||||
|
||||
Client configuration changes over time. Before giving client-specific steps, fetch the current source of truth:
|
||||
|
||||
```text
|
||||
https://docs.composio.dev/docs/composio-connect.md
|
||||
```
|
||||
|
||||
For any other MCP-capable client, configure HTTP transport with the endpoint and, when required, the `x-consumer-api-key` header. Keep credentials out of committed configuration.
|
||||
|
||||
### Terminal agents
|
||||
|
||||
Install and authenticate the CLI only when the task needs it:
|
||||
|
||||
```bash
|
||||
curl -fsSL https://composio.dev/install | bash
|
||||
composio login
|
||||
```
|
||||
|
||||
For a real task:
|
||||
|
||||
```bash
|
||||
composio search "<what the user wants>"
|
||||
composio link <toolkit>
|
||||
composio execute <TOOL_SLUG> -d '{...}'
|
||||
```
|
||||
|
||||
Use `composio login --no-wait | jq` when the agent cannot open a browser. Give the returned login URL to the user and complete authentication with the returned key. Once installed, prefer the bundled `composio-cli` skill for current command and flag details.
|
||||
|
||||
## Connect apps when the task needs them
|
||||
|
||||
Do not pre-connect every app. Start the requested task. When an integration is required, Composio returns an authorization link and the connection persists for future runs.
|
||||
|
||||
For setup or an operational request, verify the selected path with one safe real call when authorization is available. For a question or configuration explanation, answer it without forcing execution.
|
||||
|
||||
## Debugging
|
||||
|
||||
First confirm that the client is connected to the correct MCP endpoint or that the CLI is authenticated. Then get the Composio log or request ID and read [Errors and provider gotchas](errors.md).
|
||||
|
||||
Common product-specific checks:
|
||||
|
||||
- If MCP tools do not appear, confirm the connector is enabled, clear its cache when the client supports that, and reconnect it.
|
||||
- If browser OAuth repeatedly selects the wrong account, retry in a clean browser profile with one Composio account signed in.
|
||||
- If an authorization link expired, request a new link rather than reusing it.
|
||||
- If a connected app action returns an auth error, reconnect that app and retry without regenerating the consumer key.
|
||||
|
||||
Manage connections in Dashboard → For You → Connect Apps. Manage consumer keys and MCP or CLI sessions in Settings → Sessions & API Key.
|
||||
|
|
@ -0,0 +1,166 @@
|
|||
# Composio Platform
|
||||
|
||||
Use this product when a developer is building an agent, application, or backend whose users connect their own accounts. Route by task instead of assuming every request is onboarding.
|
||||
|
||||
## Contents
|
||||
|
||||
1. [Choose the task](#choose-the-task)
|
||||
2. [Establish project access](#establish-project-access)
|
||||
3. [Integrate sessions](#integrate-sessions)
|
||||
4. [Choose tools and authentication behavior](#choose-tools-and-authentication-behavior)
|
||||
5. [Handle advanced product work](#handle-advanced-product-work)
|
||||
6. [Verify setup when relevant](#verify-setup-when-relevant)
|
||||
7. [Use canonical documentation](#use-canonical-documentation)
|
||||
|
||||
## Choose the task
|
||||
|
||||
- **Explain or discover:** answer from this guide and current documentation without changing code.
|
||||
- **First-time setup:** establish project access and the smallest working SDK path.
|
||||
- **Integrate or extend:** inspect the codebase and add Composio to the existing agent architecture.
|
||||
- **Operate:** discover, authorize, and execute tools for the application's current user.
|
||||
- **Debug or migrate:** inspect the log ID and current implementation before changing credentials or architecture.
|
||||
|
||||
When modifying code, first inspect and identify the language, package manager, agent or LLM framework, stable user or tenant ID, secret-loading mechanism, and smallest existing execution path where Composio tools belong. Extend that path; do not create a parallel demo agent when one already exists. Never infer filenames, framework choices, environment behavior, or identity fields that were not provided or observed.
|
||||
|
||||
Use progressive disclosure. The basic path is project access, the core SDK, one user-scoped session, and the existing agent's tool interface. Do not add toolkit filters, tag policies, sandbox changes, custom auth, provider adapters, or production hardening unless the request or inspected code requires them. If essential repository context is missing, give the minimum stable outline and ask only for the missing detail instead of filling a large example with placeholders.
|
||||
|
||||
## Establish project access
|
||||
|
||||
Choose exactly one credential path from context.
|
||||
|
||||
### Existing dashboard or repository credential
|
||||
|
||||
If `COMPOSIO_API_KEY` already exists, or the developer arrived from Dashboard Getting Started after copying an `ak_*` project key, use that credential from the repository's existing environment or secret mechanism.
|
||||
|
||||
In this path:
|
||||
|
||||
- Never run `composio dev init` or select another project.
|
||||
- Never create, rotate, replace, print, echo, log, or request the key in chat.
|
||||
- Check only whether the environment variable exists and is not visibly masked or placeholder-like.
|
||||
- When it lives in a file, check that the file is ignored by source control without printing the matching line.
|
||||
- Let the first SDK request validate the credential; length is not validation.
|
||||
|
||||
If the dashboard handoff is missing or masked, direct the developer back to Platform → project → Getting Started → Step 1. Do not silently switch to a provisioning flow.
|
||||
|
||||
### General first-time setup
|
||||
|
||||
If there is no existing project credential and no dashboard handoff, use the current first-time setup path:
|
||||
|
||||
```bash
|
||||
curl -fsSL https://composio.dev/install | bash
|
||||
composio login
|
||||
composio dev init
|
||||
```
|
||||
|
||||
`composio dev init` writes `COMPOSIO_API_KEY` and `COMPOSIO_TEST_USER_ID` to `.env.local`. Python dotenv does not load `.env.local` by default, so pass the path explicitly or move the variables through the project's normal secret mechanism. There is no bare `composio init` command.
|
||||
|
||||
Verification stamp: these commands were exercised against CLI 0.2.32 and 0.3.1 on 2026-08-06. If the installed version differs or behavior conflicts, check `composio dev --help` and current docs rather than forcing the stamped behavior.
|
||||
|
||||
### Install the SDK the codebase needs
|
||||
|
||||
```bash
|
||||
npm install @composio/core
|
||||
pip install composio
|
||||
```
|
||||
|
||||
Add a provider adapter only when the existing framework needs one. Fetch the current provider index before naming a package:
|
||||
|
||||
```text
|
||||
https://docs.composio.dev/docs/providers.md
|
||||
```
|
||||
|
||||
Do not introduce another LLM framework solely to demonstrate Composio.
|
||||
|
||||
## Integrate sessions
|
||||
|
||||
A session is the runtime context for one application user. It carries identity, connections, tool scope, and sandbox configuration.
|
||||
|
||||
Trace the application's existing authenticated user or tenant ID and use that stable identifier. Do not add a parallel user system or share one placeholder identity across users.
|
||||
|
||||
TypeScript:
|
||||
|
||||
```typescript
|
||||
import { Composio } from "@composio/core";
|
||||
|
||||
const composio = new Composio();
|
||||
const session = await composio.create(existingUserId);
|
||||
const tools = await session.tools();
|
||||
```
|
||||
|
||||
Python:
|
||||
|
||||
```python
|
||||
from composio import Composio
|
||||
|
||||
composio = Composio()
|
||||
session = composio.create(user_id=existing_user_id)
|
||||
tools = session.tools()
|
||||
```
|
||||
|
||||
Both SDKs also expose `composio.sessions.create(...)`; do not teach an artificial TypeScript/Python asymmetry. The SDK reads `COMPOSIO_API_KEY` from the environment, so do not pass it inline.
|
||||
|
||||
For a multi-turn conversation, persist the returned session ID and resume it instead of creating a fresh session on every message. Confirm current method names against `configuring-sessions.md` before writing production code.
|
||||
|
||||
Pass the session tools to the repository's existing model or agent using its native tool integration. Preserve the current prompt, model, streaming, and request lifecycle unless tools require a targeted change.
|
||||
|
||||
## Choose tools and authentication behavior
|
||||
|
||||
Sessions expose a small set of meta tools by default so the agent can discover integrations and authenticate at runtime:
|
||||
|
||||
- `COMPOSIO_SEARCH_TOOLS`
|
||||
- `COMPOSIO_GET_TOOL_SCHEMAS`
|
||||
- `COMPOSIO_MULTI_EXECUTE_TOOL`
|
||||
- `COMPOSIO_MANAGE_CONNECTIONS`
|
||||
- `COMPOSIO_WAIT_FOR_CONNECTIONS`
|
||||
- `COMPOSIO_REMOTE_WORKBENCH`
|
||||
- `COMPOSIO_REMOTE_BASH_TOOL`
|
||||
|
||||
Keep connection management enabled for interactive agents. It returns a Connect Link when a user needs to authorize an app; do not build a provider OAuth flow.
|
||||
|
||||
Use the direct-tools preset only for a narrow, deterministic agent with a fixed allowlist. It removes meta tools by default. Re-enable connection management when users must authenticate in the agent, and keep or disable the sandbox deliberately. Fetch `configuring-sessions.md` for the current preset and option syntax before implementing it.
|
||||
|
||||
If the application has its own connect UI, use session authorization and connection-state methods and suppress in-chat connection prompts. Use managed auth initially. Create a custom auth config only for the application's OAuth branding, additional scopes, dedicated provider quotas, or self-hosted or regional requirements.
|
||||
|
||||
## Handle advanced product work
|
||||
|
||||
Do not force advanced requests through first-time setup. Route them to current documentation:
|
||||
|
||||
- Session scoping, account selection, callbacks, direct tools, and sandbox controls: `configuring-sessions.md`
|
||||
- Custom connection UI: `manually-authenticating.md`
|
||||
- Triggers and webhooks: `triggers.md` and the setting-up-triggers guides
|
||||
- Custom MCP servers, tools, toolkits, or proxy execution: the `extending-sessions` guides
|
||||
- Legacy direct execution, MCP servers, or Tool Router migrations: the migration and sessions guides
|
||||
- White labeling and custom OAuth apps: `white-labeling-authentication.md`
|
||||
|
||||
"Tool Router" is the former name for sessions. Treat direct execution as a migration path, not the default for new agent integrations.
|
||||
|
||||
## Verify setup when relevant
|
||||
|
||||
For a first-time setup or integration request, success means a programmatic, safe, read-only tool call from the developer's real execution path returns an actual provider result and a non-empty Composio log ID.
|
||||
|
||||
Ask which integration the developer wants to try unless the application already makes the choice clear. Discover the real toolkit and tool at runtime. If the current user is not connected, return the Connect Link, wait for authorization, and retry.
|
||||
|
||||
A mock, Playground run, tool search, schema fetch, session creation, or Connect Link alone does not prove the integration. If the repository has no runnable agent loop, add only the smallest entrypoint compatible with its existing provider; do not require another hosted model.
|
||||
|
||||
After success, report the code location, identity and session mapping, integration and tool, safe result summary, log ID, and useful dashboard destinations. For an explanation, migration plan, or narrow bug fix, use that task's own completion condition instead of forcing a new tool call.
|
||||
|
||||
## Use canonical documentation
|
||||
|
||||
Fetch current Markdown before giving version-sensitive commands or editing SDK integration code:
|
||||
|
||||
```text
|
||||
https://docs.composio.dev/llms.txt
|
||||
https://docs.composio.dev/docs/<page>.md
|
||||
https://docs.composio.dev/toolkits/<toolkit>.md
|
||||
```
|
||||
|
||||
Read [Errors and provider gotchas](errors.md) for failures shared across products.
|
||||
|
||||
## Do not
|
||||
|
||||
- Do not replace a dashboard-provided credential with a general setup flow.
|
||||
- Do not stop at documentation when the user asked for implementation.
|
||||
- Do not guess toolkit or tool slugs.
|
||||
- Do not create auth configs as a universal prerequisite.
|
||||
- Do not replace the application's identity model or agent architecture.
|
||||
- Do not claim an integration works before the requested proof succeeds.
|
||||
|
|
@ -0,0 +1,25 @@
|
|||
.git
|
||||
.github
|
||||
.vercel
|
||||
.turbo
|
||||
.cache
|
||||
.DS_Store
|
||||
.env
|
||||
.env.*
|
||||
node_modules
|
||||
**/node_modules
|
||||
**/dist
|
||||
**/build
|
||||
**/out
|
||||
**/.expo
|
||||
**/.astro
|
||||
coverage
|
||||
data
|
||||
artifacts
|
||||
backups
|
||||
design
|
||||
PRODUCT_PLAN.md
|
||||
playwright-report
|
||||
test-results
|
||||
verify-report
|
||||
*.log
|
||||
|
|
@ -0,0 +1,123 @@
|
|||
NODE_ENV=development
|
||||
DATABASE_URL=postgres://rakazo:rakazo@127.0.0.1:5433/rakazo
|
||||
BETTER_AUTH_SECRET=replace-with-32-plus-character-secret
|
||||
BETTER_AUTH_URL=http://127.0.0.1:5173
|
||||
API_URL=http://127.0.0.1:3100
|
||||
# Listener address. Keep loopback unless a container or trusted reverse proxy must reach the API.
|
||||
API_HOST=127.0.0.1
|
||||
WEB_ORIGIN=http://127.0.0.1:5173
|
||||
PUBLIC_POSTHOG_KEY=
|
||||
PUBLIC_POSTHOG_HOST=https://us.i.posthog.com
|
||||
SIGNUPS_ENABLED=true
|
||||
SIGNUP_ALLOWLIST=
|
||||
ENCRYPTION_KEY=replace-with-64-random-hex-characters
|
||||
DATA_DIR=./data
|
||||
SANDBOX_SUPERVISOR_URL=http://127.0.0.1:7091
|
||||
# Dedicated credentials (distinct from BETTER_AUTH_SECRET). Required for Docker sandboxes /
|
||||
# screen proxy outside local development.
|
||||
SANDBOX_SUPERVISOR_TOKEN=replace-with-32-plus-character-supervisor-token
|
||||
SCREEN_PROXY_SECRET=replace-with-32-plus-character-screen-proxy-secret
|
||||
SANDBOX_PROVIDER=docker
|
||||
# Optional Docker daemon socket. Defaults to /var/run/docker.sock (Unix) or
|
||||
# //./pipe/docker_engine (Windows). Set for rootless Docker, Colima, or custom paths.
|
||||
# DOCKER_HOST takes precedence over DOCKER_SOCKET when both are set.
|
||||
# DOCKER_SOCKET=
|
||||
DAYTONA_API_KEY=
|
||||
DAYTONA_API_URL=
|
||||
DAYTONA_TARGET=
|
||||
BOX_API_KEY=
|
||||
# Optional Box API base URL; defaults to https://ascii.dev/api/box/v1.
|
||||
BOX_API_URL=
|
||||
AGENT_RUNTIME=pi
|
||||
WAKEUP_DRIVER=graphile
|
||||
# Pause or stop computers after this many idle ms. Minimum 30000.
|
||||
SANDBOX_IDLE_MS=600000
|
||||
SANDBOX_COMMAND_TIMEOUT_MS=300000
|
||||
# Optional per-turn tool-call fuse for the Pi agent runtime. Unset, empty, or 0
|
||||
# means unlimited (default). Set a positive integer to soft-stop a turn that
|
||||
# exceeds the budget and still emit a final assistant message.
|
||||
MAX_TOOL_CALLS_PER_TURN=
|
||||
# Deployment-wide fallback model. PI_DEFAULT_PROVIDER picks which key below is used.
|
||||
OPENROUTER_API_KEY=
|
||||
ANTHROPIC_API_KEY=
|
||||
PI_DEFAULT_PROVIDER=openrouter
|
||||
# Text-only by default. Computer use (screenshots via computer_observe / computer_act)
|
||||
# needs a vision-capable model — one whose catalog entry lists image input modalities
|
||||
# (e.g. openai/gpt-4o). Tests use COMPUTER_E2E_MODEL for that.
|
||||
PI_DEFAULT_MODEL=deepseek/deepseek-v4-flash-0731
|
||||
# Optional local OpenAI-compatible models. Leave blank to disable the provider.
|
||||
RAKAZO_LOCAL_MODELS=
|
||||
RAKAZO_LOCAL_MODELS_URL=http://127.0.0.1:11434/v1
|
||||
RAKAZO_LOCAL_CONTEXT_WINDOW=32768
|
||||
RAKAZO_LOCAL_MAX_TOKENS=4096
|
||||
# Allow user-connected OpenAI-compatible endpoints on public hostnames (default: private/loopback only).
|
||||
RAKAZO_OPENAI_COMPAT_ALLOW_PUBLIC=
|
||||
E2B_API_KEY=
|
||||
COMPOSIO_API_KEY=
|
||||
# Optional alternative managed connector provider.
|
||||
PIPEDREAM_CLIENT_ID=
|
||||
PIPEDREAM_CLIENT_SECRET=
|
||||
PIPEDREAM_PROJECT_ID=
|
||||
PIPEDREAM_ENVIRONMENT=development
|
||||
# Optional messaging surface (Chat SDK). Each platform mounts when its full
|
||||
# credential set is present; webhooks arrive at /api/v1/messaging/webhook/<provider>.
|
||||
# Users link a chat app to their account from the web (Messaging settings).
|
||||
# Set true to instead let unknown senders auto-provision their own chat-only
|
||||
# accounts (their runs bill the deployment model key):
|
||||
MESSAGING_OPEN_SIGNUP=false
|
||||
# Sendblue (iMessage/SMS — all four required):
|
||||
SENDBLUE_API_KEY_ID=
|
||||
SENDBLUE_API_SECRET=
|
||||
SENDBLUE_SIGNING_SECRET=
|
||||
SENDBLUE_PHONE_NUMBER=
|
||||
# Slack (both required):
|
||||
SLACK_BOT_TOKEN=
|
||||
SLACK_SIGNING_SECRET=
|
||||
# WhatsApp Business Cloud (all four required):
|
||||
WHATSAPP_ACCESS_TOKEN=
|
||||
WHATSAPP_PHONE_NUMBER_ID=
|
||||
WHATSAPP_APP_SECRET=
|
||||
WHATSAPP_VERIFY_TOKEN=
|
||||
# Telegram (both required). Inbound is webhook-only at
|
||||
# /api/v1/messaging/webhook/telegram with header X-Telegram-Bot-Api-Secret-Token;
|
||||
# the worker must not long-poll getUpdates.
|
||||
TELEGRAM_BOT_TOKEN=
|
||||
TELEGRAM_WEBHOOK_SECRET_TOKEN=
|
||||
SUPERMEMORY_API_KEY=
|
||||
# Optional. Defaults to https://api.supermemory.ai. Self-hosted: http://localhost:6767
|
||||
SUPERMEMORY_API_URL=
|
||||
# Transactional account email. Works with Resend, Amazon SES, or any SMTP server.
|
||||
SMTP_URL=
|
||||
EMAIL_FROM=
|
||||
# Local development only: capture password-reset email and print its link to the API console.
|
||||
EMAIL_EMULATOR=
|
||||
VAPID_PUBLIC_KEY=
|
||||
VAPID_PRIVATE_KEY=
|
||||
OTEL_EXPORTER_OTLP_ENDPOINT=
|
||||
LOG_LEVEL=info
|
||||
# Optional. GET /health returns this as `revision` so you can confirm the deployed commit.
|
||||
GIT_SHA=
|
||||
|
||||
# --- Compose production deployments ---
|
||||
# Absolute path of this checkout as the Docker *daemon* sees it. Production Compose defaults to
|
||||
# /srv/rakazo on Linux; override it for any other host path so the updater bind mount resolves.
|
||||
# Linux override: RAKAZO_DEPLOY_DIR=/opt/rakazo
|
||||
# Docker Desktop on Windows (C:\Users\you\rakazo):
|
||||
# RAKAZO_DEPLOY_DIR=/run/desktop/mnt/host/c/Users/you/rakazo
|
||||
RAKAZO_DEPLOY_DIR=
|
||||
# Published images (defaults match this repository). Tag `local` is built from the checkout.
|
||||
RAKAZO_IMAGE=ghcr.io/elie222/rakazo/app
|
||||
RAKAZO_IMAGE_TAG=local
|
||||
RAKAZO_IMAGE_TAG_PREVIOUS=
|
||||
RAKAZO_UPDATER_IMAGE=ghcr.io/elie222/rakazo/updater
|
||||
RAKAZO_UPDATER_IMAGE_TAG=local
|
||||
# Required only when starting the opt-in `updater` Compose profile; the sidecar refuses to start
|
||||
# without it. Use a dedicated random value, not BETTER_AUTH_SECRET,
|
||||
# SANDBOX_SUPERVISOR_TOKEN, or SCREEN_PROXY_SECRET;
|
||||
# ≥32 chars in production. Leave empty when the sidecar is disabled.
|
||||
RAKAZO_UPDATER_URL=
|
||||
RAKAZO_UPDATER_TOKEN=
|
||||
|
||||
# Optional default UI locale for the web SPA (en | de | ko). Overridden by
|
||||
# localStorage key rakazo.uiLocale when the user picks a language in Settings.
|
||||
# VITE_DEFAULT_UI_LOCALE=en
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
* text=auto eol=lf
|
||||
|
||||
*.sh text eol=lf
|
||||
infra/sandboxes/computer/rakazo-browser text eol=lf
|
||||
infra/sandboxes/computer/fluxbox.* text eol=lf
|
||||
*.png binary
|
||||
*.jpg binary
|
||||
*.ico binary
|
||||
|
|
@ -0,0 +1,94 @@
|
|||
name: Bug report
|
||||
description: Something is broken in Rakazo
|
||||
title: "[Bug]: "
|
||||
labels:
|
||||
- bug
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
For **security vulnerabilities**, email [security@rakazo.com](mailto:security@rakazo.com) — do not use this form. See [SECURITY.md](https://github.com/elie222/rakazo/blob/main/SECURITY.md).
|
||||
|
||||
- type: checkboxes
|
||||
id: not-security
|
||||
attributes:
|
||||
label: Confirmation
|
||||
options:
|
||||
- label: This is not a security vulnerability report
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: description
|
||||
attributes:
|
||||
label: Description
|
||||
description: What went wrong? Keep it concise.
|
||||
placeholder: The bot computer pane stays blank after sending a message.
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: steps
|
||||
attributes:
|
||||
label: Steps to reproduce
|
||||
description: Numbered steps from a clean local setup when possible.
|
||||
placeholder: |
|
||||
1. `pnpm dev`
|
||||
2. Create a bot and send "hello"
|
||||
3. Open the computer pane
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: expected
|
||||
attributes:
|
||||
label: Expected behavior
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: actual
|
||||
attributes:
|
||||
label: Actual behavior
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: input
|
||||
id: version
|
||||
attributes:
|
||||
label: Rakazo version or commit
|
||||
placeholder: v0.1.0 or abc1234
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: dropdown
|
||||
id: os
|
||||
attributes:
|
||||
label: Operating system
|
||||
options:
|
||||
- macOS
|
||||
- Linux
|
||||
- Windows
|
||||
- Other
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: input
|
||||
id: sandbox
|
||||
attributes:
|
||||
label: SANDBOX_PROVIDER
|
||||
description: If relevant (default is `docker`).
|
||||
placeholder: docker
|
||||
|
||||
- type: input
|
||||
id: runtime
|
||||
attributes:
|
||||
label: AGENT_RUNTIME
|
||||
description: If relevant (default is `pi`).
|
||||
placeholder: pi
|
||||
|
||||
- type: textarea
|
||||
id: logs
|
||||
attributes:
|
||||
label: Logs or screenshots
|
||||
description: Redact secrets and tokens. Use placeholders instead of real keys.
|
||||
render: shell
|
||||
|
|
@ -0,0 +1,11 @@
|
|||
blank_issues_enabled: false
|
||||
contact_links:
|
||||
- name: Security vulnerabilities
|
||||
url: https://github.com/elie222/rakazo/blob/main/SECURITY.md
|
||||
about: Report security issues to security@rakazo.com — do not open a public issue.
|
||||
- name: User support
|
||||
url: mailto:support@rakazo.com
|
||||
about: Questions about using Rakazo or self-hosting help.
|
||||
- name: Maintainer
|
||||
url: mailto:elie@rakazo.com
|
||||
about: Reach the maintainer directly.
|
||||
|
|
@ -0,0 +1,80 @@
|
|||
name: Self-host setup
|
||||
description: Cannot start Rakazo, run compose, migrate, or build the sandbox
|
||||
title: "[Self-host]: "
|
||||
labels:
|
||||
- self-host
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
For **security vulnerabilities**, email [security@rakazo.com](mailto:security@rakazo.com). See [SECURITY.md](https://github.com/elie222/rakazo/blob/main/SECURITY.md).
|
||||
|
||||
Setup steps are in [README.md](https://github.com/elie222/rakazo/blob/main/README.md). Do not paste `.env` contents or API keys.
|
||||
|
||||
- type: dropdown
|
||||
id: step
|
||||
attributes:
|
||||
label: Which step fails?
|
||||
options:
|
||||
- Docker Compose / Postgres
|
||||
- pnpm install
|
||||
- pnpm db:generate or db:migrate
|
||||
- pnpm sandbox:build
|
||||
- pnpm dev (API, worker, web, or supervisor)
|
||||
- Health check / first login
|
||||
- Other
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: command
|
||||
attributes:
|
||||
label: Command you ran
|
||||
placeholder: pnpm sandbox:build
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: error
|
||||
attributes:
|
||||
label: Error output
|
||||
description: Full terminal output or log excerpt. Redact secrets.
|
||||
render: shell
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: input
|
||||
id: version
|
||||
attributes:
|
||||
label: Rakazo version or commit
|
||||
placeholder: main @ abc1234
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: dropdown
|
||||
id: os
|
||||
attributes:
|
||||
label: Operating system
|
||||
options:
|
||||
- macOS
|
||||
- Linux
|
||||
- Windows
|
||||
- Other
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: input
|
||||
id: docker
|
||||
attributes:
|
||||
label: Docker version
|
||||
placeholder: Docker Desktop 4.x / docker 27.x
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: input
|
||||
id: node
|
||||
attributes:
|
||||
label: Node.js version
|
||||
placeholder: v22.x
|
||||
validations:
|
||||
required: true
|
||||
|
|
@ -0,0 +1,293 @@
|
|||
name: ci
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: ci-${{ github.event.pull_request.number || github.ref }}
|
||||
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
|
||||
|
||||
jobs:
|
||||
lint:
|
||||
name: Lint
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- uses: pnpm/action-setup@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
cache: pnpm
|
||||
- run: pnpm install --frozen-lockfile
|
||||
- run: pnpm lint
|
||||
|
||||
check:
|
||||
name: Typecheck
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- uses: pnpm/action-setup@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
cache: pnpm
|
||||
- run: pnpm install --frozen-lockfile
|
||||
- run: pnpm db:generate
|
||||
env:
|
||||
DATABASE_URL: postgres://rakazo:rakazo@127.0.0.1:5433/rakazo
|
||||
- run: pnpm check
|
||||
|
||||
build:
|
||||
name: Production builds
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- uses: pnpm/action-setup@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
cache: pnpm
|
||||
- run: pnpm install --frozen-lockfile
|
||||
- run: pnpm build
|
||||
env:
|
||||
DATABASE_URL: postgres://rakazo:rakazo@127.0.0.1:5433/rakazo
|
||||
BETTER_AUTH_SECRET: ci-build-auth-secret-at-least-32-characters
|
||||
ENCRYPTION_KEY: ci-build-encryption-key-at-least-32-characters
|
||||
- name: Electron smoke
|
||||
run: xvfb-run --auto-servernum pnpm --filter @rakazo/desktop exec playwright test --config e2e/playwright.config.ts
|
||||
|
||||
test:
|
||||
name: Unit tests
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- uses: pnpm/action-setup@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
cache: pnpm
|
||||
- run: pnpm install --frozen-lockfile
|
||||
- run: pnpm db:generate
|
||||
env:
|
||||
DATABASE_URL: postgres://rakazo:rakazo@127.0.0.1:5433/rakazo
|
||||
- run: pnpm test
|
||||
|
||||
test-integration:
|
||||
name: Postgres journeys
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- uses: pnpm/action-setup@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
cache: pnpm
|
||||
- run: pnpm install --frozen-lockfile
|
||||
- run: pnpm test:integration
|
||||
|
||||
test-e2e:
|
||||
name: Web E2E
|
||||
uses: ./.github/workflows/playwright.yml
|
||||
with:
|
||||
upload_artifacts: true
|
||||
|
||||
publish-mobile-update:
|
||||
name: Publish compatible mobile update
|
||||
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
|
||||
needs: [lint, check, build, test, test-integration, test-e2e]
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 20
|
||||
concurrency:
|
||||
group: mobile-production-update
|
||||
cancel-in-progress: false
|
||||
steps:
|
||||
- uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5
|
||||
with:
|
||||
fetch-depth: 0
|
||||
persist-credentials: false
|
||||
|
||||
- name: Check whether the change is safe for OTA
|
||||
id: ota
|
||||
shell: bash
|
||||
env:
|
||||
BEFORE_SHA: ${{ github.event.before }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
base="$BEFORE_SHA"
|
||||
if [[ -z "$base" || "$base" =~ ^0+$ ]] || ! git cat-file -e "${base}^{commit}"; then
|
||||
{
|
||||
echo "### Mobile OTA skipped"
|
||||
echo
|
||||
echo "Could not resolve \`github.event.before\` for the full push range, so this job will not publish a partial-range update."
|
||||
} >> "$GITHUB_STEP_SUMMARY"
|
||||
echo "publish=false" >> "$GITHUB_OUTPUT"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
mapfile -t files < <(
|
||||
git diff --name-only "$base" "$GITHUB_SHA" -- \
|
||||
.github/workflows/ci.yml \
|
||||
apps/mobile \
|
||||
package.json \
|
||||
packages/chat-ui \
|
||||
packages/contracts \
|
||||
packages/core \
|
||||
pnpm-lock.yaml
|
||||
)
|
||||
|
||||
publish=false
|
||||
blocked=false
|
||||
for file in "${files[@]}"; do
|
||||
case "$file" in
|
||||
*.test.ts|*.test.tsx|*.d.ts)
|
||||
continue
|
||||
;;
|
||||
esac
|
||||
|
||||
eligible=false
|
||||
case "$file" in
|
||||
apps/mobile/app/*|apps/mobile/components/*|apps/mobile/lib/*)
|
||||
case "$file" in
|
||||
*.ts|*.tsx) eligible=true ;;
|
||||
esac
|
||||
;;
|
||||
packages/chat-ui/src/*)
|
||||
case "$file" in
|
||||
*.ts|*.tsx|*.css) eligible=true ;;
|
||||
esac
|
||||
;;
|
||||
packages/contracts/src/*|packages/core/src/*)
|
||||
case "$file" in
|
||||
*.ts|*.tsx) eligible=true ;;
|
||||
esac
|
||||
;;
|
||||
esac
|
||||
|
||||
if [[ "$eligible" == true ]]; then
|
||||
publish=true
|
||||
else
|
||||
blocked=true
|
||||
fi
|
||||
done
|
||||
|
||||
if [[ "$blocked" == true ]]; then
|
||||
publish=false
|
||||
{
|
||||
echo "### Mobile OTA skipped"
|
||||
echo
|
||||
echo "This revision changes native-sensitive mobile configuration, dependencies, modules, assets, or workflow files. Create new iOS and Android builds instead of publishing it to an older native runtime."
|
||||
} >> "$GITHUB_STEP_SUMMARY"
|
||||
elif [[ "$publish" == true ]]; then
|
||||
{
|
||||
echo "### Mobile OTA eligible"
|
||||
echo
|
||||
echo "The revision only changes JavaScript, TypeScript, or bundled CSS used by the mobile client."
|
||||
} >> "$GITHUB_STEP_SUMMARY"
|
||||
else
|
||||
{
|
||||
echo "### No mobile OTA needed"
|
||||
echo
|
||||
echo "This revision does not change the shipped mobile bundle."
|
||||
} >> "$GITHUB_STEP_SUMMARY"
|
||||
fi
|
||||
echo "publish=$publish" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Require Expo automation token
|
||||
if: steps.ota.outputs.publish == 'true'
|
||||
env:
|
||||
EXPO_TOKEN: ${{ secrets.EXPO_TOKEN }}
|
||||
run: |
|
||||
if [[ -z "$EXPO_TOKEN" ]]; then
|
||||
echo "Add an EXPO_TOKEN repository secret with access to the inbox-zero/rakazo Expo project." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- uses: pnpm/action-setup@f40ffcd9367d9f12939873eb1018b921a783ffaa # v4
|
||||
if: steps.ota.outputs.publish == 'true'
|
||||
- uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6
|
||||
if: steps.ota.outputs.publish == 'true'
|
||||
with:
|
||||
node-version: 24
|
||||
cache: pnpm
|
||||
- name: Set up Expo
|
||||
if: steps.ota.outputs.publish == 'true'
|
||||
uses: expo/expo-github-action@eab7a230208c952974db8c3245cfd78402c7b385 # v9
|
||||
with:
|
||||
eas-version: latest
|
||||
packager: pnpm
|
||||
token: ${{ secrets.EXPO_TOKEN }}
|
||||
- name: Install dependencies
|
||||
if: steps.ota.outputs.publish == 'true'
|
||||
run: pnpm install --frozen-lockfile
|
||||
- name: Verify mobile project
|
||||
if: steps.ota.outputs.publish == 'true'
|
||||
run: pnpm --filter @rakazo/mobile check
|
||||
- name: Publish production update
|
||||
if: steps.ota.outputs.publish == 'true'
|
||||
working-directory: apps/mobile
|
||||
run: |
|
||||
set -euo pipefail
|
||||
head="$(git ls-remote origin refs/heads/main | awk '{print $1}')"
|
||||
if [[ -z "$head" ]]; then
|
||||
echo "Could not resolve the current main head before publishing." >&2
|
||||
exit 1
|
||||
fi
|
||||
if [[ "$GITHUB_SHA" != "$head" ]]; then
|
||||
{
|
||||
echo "### Mobile OTA skipped"
|
||||
echo
|
||||
echo "This run is for \`$GITHUB_SHA\`, but \`main\` is now \`$head\`. Skipping so an older revision cannot overwrite a newer production update."
|
||||
} >> "$GITHUB_STEP_SUMMARY"
|
||||
exit 0
|
||||
fi
|
||||
eas update --platform all --channel production --environment production --message "$GITHUB_SHA" --non-interactive
|
||||
|
||||
deploy-production:
|
||||
if: >-
|
||||
github.event_name == 'push' &&
|
||||
github.ref == 'refs/heads/main' &&
|
||||
vars.PRODUCTION_DEPLOY_ENABLED == 'true'
|
||||
needs: [lint, check, build, test, test-integration, test-e2e]
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
concurrency:
|
||||
group: production
|
||||
cancel-in-progress: false
|
||||
environment:
|
||||
name: production
|
||||
url: https://app.rakazo.com
|
||||
steps:
|
||||
- name: Configure production SSH identity
|
||||
env:
|
||||
SSH_PRIVATE_KEY: ${{ secrets.PRODUCTION_SSH_PRIVATE_KEY }}
|
||||
SSH_KNOWN_HOSTS: ${{ secrets.PRODUCTION_SSH_KNOWN_HOSTS }}
|
||||
run: |
|
||||
test -n "$SSH_PRIVATE_KEY"
|
||||
test -n "$SSH_KNOWN_HOSTS"
|
||||
install -d -m 700 "$HOME/.ssh"
|
||||
printf '%s\n' "$SSH_PRIVATE_KEY" > "$HOME/.ssh/rakazo-production"
|
||||
printf '%s\n' "$SSH_KNOWN_HOSTS" > "$HOME/.ssh/known_hosts"
|
||||
chmod 600 "$HOME/.ssh/rakazo-production" "$HOME/.ssh/known_hosts"
|
||||
ssh-keygen -y -f "$HOME/.ssh/rakazo-production" >/dev/null
|
||||
|
||||
- name: Deploy successful main revision
|
||||
env:
|
||||
SSH_HOST: ${{ secrets.PRODUCTION_SSH_HOST }}
|
||||
SSH_USER: ${{ secrets.PRODUCTION_SSH_USER }}
|
||||
run: |
|
||||
test -n "$SSH_HOST"
|
||||
test -n "$SSH_USER"
|
||||
ssh -F /dev/null \
|
||||
-i "$HOME/.ssh/rakazo-production" \
|
||||
-o BatchMode=yes \
|
||||
-o ConnectTimeout=15 \
|
||||
-o IdentitiesOnly=yes \
|
||||
-o StrictHostKeyChecking=yes \
|
||||
-o UserKnownHostsFile="$HOME/.ssh/known_hosts" \
|
||||
"$SSH_USER@$SSH_HOST" deploy-main
|
||||
|
|
@ -0,0 +1,77 @@
|
|||
name: nightly verification
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: "23 2 * * *"
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
run_live_providers:
|
||||
description: Run secret-gated OpenRouter, E2B, and Box canaries
|
||||
required: false
|
||||
type: boolean
|
||||
default: false
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: nightly-verification
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
visual-web:
|
||||
name: Daily web screenshots
|
||||
uses: ./.github/workflows/playwright.yml
|
||||
with:
|
||||
publish_report: true
|
||||
secrets:
|
||||
S3_ACCESS_KEY_ID: ${{ secrets.S3_ACCESS_KEY_ID }}
|
||||
S3_SECRET_ACCESS_KEY: ${{ secrets.S3_SECRET_ACCESS_KEY }}
|
||||
|
||||
topology:
|
||||
name: Production topology smoke
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- uses: pnpm/action-setup@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
cache: pnpm
|
||||
- run: pnpm install --frozen-lockfile
|
||||
- run: pnpm test:topology
|
||||
|
||||
live-providers:
|
||||
name: Live provider canaries
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
env:
|
||||
E2B_API_KEY: ${{ secrets.E2B_API_KEY }}
|
||||
BOX_API_KEY: ${{ secrets.BOX_API_KEY }}
|
||||
OPENROUTER_API_KEY: ${{ secrets.OPENROUTER_API_KEY }}
|
||||
RUN_LIVE_PROVIDERS: ${{ github.event_name == 'schedule' || inputs.run_live_providers }}
|
||||
steps:
|
||||
- name: Check secret-gated canaries
|
||||
id: gate
|
||||
shell: bash
|
||||
run: |
|
||||
if [[ "$RUN_LIVE_PROVIDERS" == "true" && ( -n "$E2B_API_KEY" || -n "$BOX_API_KEY" || -n "$OPENROUTER_API_KEY" ) ]]; then
|
||||
echo "enabled=true" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "No requested live canary has a configured key; skipping safely."
|
||||
echo "enabled=false" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
- if: steps.gate.outputs.enabled == 'true'
|
||||
uses: actions/checkout@v5
|
||||
- if: steps.gate.outputs.enabled == 'true'
|
||||
uses: pnpm/action-setup@v4
|
||||
- if: steps.gate.outputs.enabled == 'true'
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
cache: pnpm
|
||||
- if: steps.gate.outputs.enabled == 'true'
|
||||
run: pnpm install --frozen-lockfile
|
||||
- if: steps.gate.outputs.enabled == 'true'
|
||||
run: pnpm test:canary
|
||||
|
|
@ -0,0 +1,137 @@
|
|||
name: Playwright
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
sandbox_provider:
|
||||
description: Sandbox provider
|
||||
required: true
|
||||
type: choice
|
||||
options:
|
||||
- fake
|
||||
- e2b
|
||||
- daytona
|
||||
- box
|
||||
default: fake
|
||||
workflow_call:
|
||||
inputs:
|
||||
sandbox_provider:
|
||||
description: Sandbox provider
|
||||
required: false
|
||||
type: string
|
||||
default: fake
|
||||
publish_report:
|
||||
description: Publish this run to the persistent visual dashboard
|
||||
required: false
|
||||
type: boolean
|
||||
default: false
|
||||
upload_artifacts:
|
||||
description: Retain the report and diagnostics as GitHub artifacts
|
||||
required: false
|
||||
type: boolean
|
||||
default: false
|
||||
secrets:
|
||||
S3_ACCESS_KEY_ID:
|
||||
description: Access key used to publish visual reports
|
||||
required: false
|
||||
S3_SECRET_ACCESS_KEY:
|
||||
description: Secret key used to publish visual reports
|
||||
required: false
|
||||
E2B_API_KEY:
|
||||
description: E2B API key used only when the E2B sandbox is selected
|
||||
required: false
|
||||
DAYTONA_API_KEY:
|
||||
description: Daytona API key used only when the Daytona sandbox is selected
|
||||
required: false
|
||||
BOX_API_KEY:
|
||||
description: Box API key used only when the Box sandbox is selected
|
||||
required: false
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
playwright:
|
||||
name: Web E2E
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 20
|
||||
concurrency:
|
||||
group: ${{ inputs.publish_report && 'playwright-publication' || format('playwright-{0}', github.run_id) }}
|
||||
cancel-in-progress: false
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- uses: pnpm/action-setup@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
cache: pnpm
|
||||
- run: pnpm install --frozen-lockfile
|
||||
- uses: actions/cache@v4
|
||||
with:
|
||||
path: ~/.cache/ms-playwright
|
||||
key: ${{ runner.os }}-playwright-${{ hashFiles('pnpm-lock.yaml') }}
|
||||
- name: Install Chromium
|
||||
timeout-minutes: 5
|
||||
# Skip --with-deps: GitHub ubuntu-latest already has Chromium's OS libraries,
|
||||
# and apt-get against azure.archive.ubuntu.com can stall until the job timeout.
|
||||
run: pnpm --filter @rakazo/web exec playwright install chromium
|
||||
- name: Validate E2B credentials
|
||||
if: inputs.sandbox_provider == 'e2b'
|
||||
env:
|
||||
E2B_API_KEY: ${{ secrets.E2B_API_KEY }}
|
||||
run: test -n "$E2B_API_KEY"
|
||||
- name: Validate Daytona credentials
|
||||
if: inputs.sandbox_provider == 'daytona'
|
||||
env:
|
||||
DAYTONA_API_KEY: ${{ secrets.DAYTONA_API_KEY }}
|
||||
run: test -n "$DAYTONA_API_KEY"
|
||||
- name: Validate Box credentials
|
||||
if: inputs.sandbox_provider == 'box'
|
||||
env:
|
||||
BOX_API_KEY: ${{ secrets.BOX_API_KEY }}
|
||||
run: test -n "$BOX_API_KEY"
|
||||
- name: Run Playwright tests
|
||||
id: playwright_tests
|
||||
env:
|
||||
SANDBOX_TEST_PROVIDER: ${{ inputs.sandbox_provider }}
|
||||
E2B_API_KEY: ${{ inputs.sandbox_provider == 'e2b' && secrets.E2B_API_KEY || '' }}
|
||||
DAYTONA_API_KEY: ${{ inputs.sandbox_provider == 'daytona' && secrets.DAYTONA_API_KEY || '' }}
|
||||
BOX_API_KEY: ${{ inputs.sandbox_provider == 'box' && secrets.BOX_API_KEY || '' }}
|
||||
run: pnpm test:e2e -- --sandbox="$SANDBOX_TEST_PROVIDER"
|
||||
- name: Record Playwright result
|
||||
if: always() && inputs.upload_artifacts
|
||||
env:
|
||||
PLAYWRIGHT_RESULT: ${{ steps.playwright_tests.outcome }}
|
||||
run: |
|
||||
mkdir -p test-report/e2e
|
||||
printf '%s\n' "$PLAYWRIGHT_RESULT" > test-report/e2e/outcome.txt
|
||||
- name: Upload Playwright artifacts
|
||||
if: always() && inputs.upload_artifacts
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: playwright-artifacts-${{ github.run_id }}-${{ github.run_attempt }}
|
||||
path: |
|
||||
playwright-report
|
||||
apps/web/test-results
|
||||
test-report/e2e
|
||||
retention-days: 7
|
||||
if-no-files-found: warn
|
||||
- name: Publish Playwright visual report
|
||||
if: always() && inputs.publish_report
|
||||
env:
|
||||
AWS_ACCESS_KEY_ID: ${{ secrets.S3_ACCESS_KEY_ID }}
|
||||
AWS_SECRET_ACCESS_KEY: ${{ secrets.S3_SECRET_ACCESS_KEY }}
|
||||
AWS_DEFAULT_REGION: ${{ vars.S3_REGION }}
|
||||
AWS_EC2_METADATA_DISABLED: "true"
|
||||
S3_BUCKET: ${{ vars.S3_BUCKET }}
|
||||
S3_ENDPOINT: ${{ vars.S3_ENDPOINT }}
|
||||
PLAYWRIGHT_PUBLIC_BASE_URL: ${{ vars.PLAYWRIGHT_PUBLIC_BASE_URL }}
|
||||
PLAYWRIGHT_RESULT: ${{ steps.playwright_tests.outcome }}
|
||||
PLAYWRIGHT_RUN_ATTEMPT: ${{ github.run_attempt }}
|
||||
PLAYWRIGHT_RUN_ID: ${{ github.run_id }}
|
||||
PLAYWRIGHT_RUN_NUMBER: ${{ github.run_number }}
|
||||
PLAYWRIGHT_RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
|
||||
PLAYWRIGHT_SHA: ${{ github.sha }}
|
||||
PLAYWRIGHT_EVENT: ${{ github.event_name }}
|
||||
PLAYWRIGHT_BRANCH: ${{ github.ref_name }}
|
||||
run: bash scripts/publish-playwright-report.sh
|
||||
|
|
@ -0,0 +1,178 @@
|
|||
name: publish Playwright report
|
||||
|
||||
on:
|
||||
workflow_run:
|
||||
workflows: [ci]
|
||||
types: [completed]
|
||||
|
||||
permissions:
|
||||
actions: read
|
||||
contents: read
|
||||
issues: write
|
||||
pull-requests: write
|
||||
|
||||
concurrency:
|
||||
group: playwright-publication
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
publish:
|
||||
if: >-
|
||||
(github.event.workflow_run.conclusion == 'success' ||
|
||||
github.event.workflow_run.conclusion == 'failure' ||
|
||||
github.event.workflow_run.conclusion == 'timed_out') &&
|
||||
(github.event.workflow_run.event == 'pull_request' ||
|
||||
(github.event.workflow_run.event == 'push' &&
|
||||
github.event.workflow_run.head_branch == 'main'))
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
# workflow_run has repository secrets. Always use trusted default-branch code here;
|
||||
# never check out or execute the contributor's pull-request revision.
|
||||
- uses: actions/checkout@v5
|
||||
with:
|
||||
ref: ${{ github.event.repository.default_branch }}
|
||||
persist-credentials: false
|
||||
- uses: pnpm/action-setup@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
cache: pnpm
|
||||
- run: pnpm install --frozen-lockfile
|
||||
- name: Read Playwright job result
|
||||
id: playwright
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
RUN_ID: ${{ github.event.workflow_run.id }}
|
||||
shell: bash
|
||||
run: |
|
||||
result="$(gh api "repos/${GITHUB_REPOSITORY}/actions/runs/${RUN_ID}/jobs?per_page=100" \
|
||||
--jq '[.jobs[] | select(.name == "Web E2E / Web E2E") | .conclusion] | unique | if length == 1 then .[0] else error("expected exactly one Playwright job") end')"
|
||||
case "$result" in
|
||||
success|failure) ;;
|
||||
timed_out) ;;
|
||||
*) echo "Unexpected Playwright job conclusion: $result" >&2; exit 1 ;;
|
||||
esac
|
||||
echo "job_result=$result" >> "$GITHUB_OUTPUT"
|
||||
if [[ "$result" == "timed_out" ]]; then result="failure"; fi
|
||||
echo "result=$result" >> "$GITHUB_OUTPUT"
|
||||
- name: Resolve pull request
|
||||
if: github.event.workflow_run.event == 'pull_request'
|
||||
id: pull_request
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
HEAD_BRANCH: ${{ github.event.workflow_run.head_branch }}
|
||||
HEAD_OWNER: ${{ github.event.workflow_run.head_repository.owner.login }}
|
||||
HEAD_SHA: ${{ github.event.workflow_run.head_sha }}
|
||||
shell: bash
|
||||
run: |
|
||||
pulls="$(gh api --method GET \
|
||||
"repos/${GITHUB_REPOSITORY}/pulls?state=all&head=${HEAD_OWNER}:${HEAD_BRANCH}&per_page=100")"
|
||||
number="$(jq -r --arg sha "$HEAD_SHA" \
|
||||
'[.[] | select(.head.sha == $sha)] | if length == 1 then .[0].number else empty end' \
|
||||
<<<"$pulls")"
|
||||
if [[ -z "$number" ]]; then
|
||||
echo "::warning::Could not resolve a pull request for ${HEAD_OWNER}:${HEAD_BRANCH} at ${HEAD_SHA}."
|
||||
fi
|
||||
echo "number=$number" >> "$GITHUB_OUTPUT"
|
||||
- name: Check for Playwright artifact
|
||||
id: artifact
|
||||
env:
|
||||
ARTIFACT_NAME: playwright-artifacts-${{ github.event.workflow_run.id }}-${{ github.event.workflow_run.run_attempt }}
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
PLAYWRIGHT_JOB_RESULT: ${{ steps.playwright.outputs.job_result }}
|
||||
RUN_ID: ${{ github.event.workflow_run.id }}
|
||||
shell: bash
|
||||
run: |
|
||||
artifacts="$(gh api --method GET \
|
||||
"repos/${GITHUB_REPOSITORY}/actions/runs/${RUN_ID}/artifacts?name=${ARTIFACT_NAME}")"
|
||||
count="$(jq --arg name "$ARTIFACT_NAME" \
|
||||
'[.artifacts[] | select(.name == $name and .expired == false)] | length' \
|
||||
<<<"$artifacts")"
|
||||
if [[ "$count" == "1" ]]; then
|
||||
echo "exists=true" >> "$GITHUB_OUTPUT"
|
||||
elif [[ "$count" == "0" && "$PLAYWRIGHT_JOB_RESULT" == "timed_out" ]]; then
|
||||
echo "::warning::The timed-out Playwright job ended before it uploaded an artifact. Publishing an empty gallery."
|
||||
echo "exists=false" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "Expected exactly one unexpired Playwright artifact; found $count." >&2
|
||||
exit 1
|
||||
fi
|
||||
- name: Download Playwright artifacts
|
||||
if: steps.artifact.outputs.exists == 'true'
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: playwright-artifacts-${{ github.event.workflow_run.id }}-${{ github.event.workflow_run.run_attempt }}
|
||||
path: .
|
||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
run-id: ${{ github.event.workflow_run.id }}
|
||||
- name: Publish trusted Playwright screenshot gallery
|
||||
id: publish
|
||||
env:
|
||||
AWS_ACCESS_KEY_ID: ${{ secrets.S3_ACCESS_KEY_ID }}
|
||||
AWS_SECRET_ACCESS_KEY: ${{ secrets.S3_SECRET_ACCESS_KEY }}
|
||||
AWS_DEFAULT_REGION: ${{ vars.S3_REGION }}
|
||||
AWS_EC2_METADATA_DISABLED: "true"
|
||||
S3_BUCKET: ${{ vars.S3_BUCKET }}
|
||||
S3_ENDPOINT: ${{ vars.S3_ENDPOINT }}
|
||||
PLAYWRIGHT_PUBLIC_BASE_URL: ${{ vars.PLAYWRIGHT_PUBLIC_BASE_URL }}
|
||||
PLAYWRIGHT_RESULT: ${{ steps.playwright.outputs.result }}
|
||||
PLAYWRIGHT_RUN_ATTEMPT: ${{ github.event.workflow_run.run_attempt }}
|
||||
PLAYWRIGHT_RUN_ID: ${{ github.event.workflow_run.id }}
|
||||
PLAYWRIGHT_RUN_NUMBER: ${{ github.event.workflow_run.run_number }}
|
||||
PLAYWRIGHT_RUN_URL: ${{ github.event.workflow_run.html_url }}
|
||||
PLAYWRIGHT_SHA: ${{ github.event.workflow_run.head_sha }}
|
||||
PLAYWRIGHT_EVENT: ${{ github.event.workflow_run.event }}
|
||||
PLAYWRIGHT_BRANCH: ${{ github.event.workflow_run.head_branch }}
|
||||
PLAYWRIGHT_PR_NUMBER: ${{ steps.pull_request.outputs.number }}
|
||||
PLAYWRIGHT_REPOSITORY_URL: ${{ github.server_url }}/${{ github.repository }}
|
||||
PLAYWRIGHT_PUBLISH_REPORT: ${{ github.event.workflow_run.event == 'push' }}
|
||||
run: bash scripts/publish-playwright-report.sh
|
||||
- name: Link screenshot gallery from pull request
|
||||
if: >-
|
||||
github.event.workflow_run.event == 'pull_request' &&
|
||||
steps.pull_request.outputs.number != '' &&
|
||||
steps.publish.outputs.latest_pr_run == 'true'
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
PLAYWRIGHT_PUBLIC_BASE_URL: ${{ vars.PLAYWRIGHT_PUBLIC_BASE_URL }}
|
||||
PR_NUMBER: ${{ steps.pull_request.outputs.number }}
|
||||
RUN_URL: ${{ github.event.workflow_run.html_url }}
|
||||
SHA: ${{ github.event.workflow_run.head_sha }}
|
||||
shell: bash
|
||||
run: |
|
||||
if [[ ! "$PR_NUMBER" =~ ^[1-9][0-9]*$ ]]; then
|
||||
echo "PR_NUMBER must be a positive integer." >&2
|
||||
exit 1
|
||||
fi
|
||||
public_base_url="${PLAYWRIGHT_PUBLIC_BASE_URL%/}"
|
||||
gallery_url="$public_base_url/prs/$PR_NUMBER/index.html"
|
||||
review_path="$GITHUB_WORKSPACE/.tmp/playwright-dashboard/screenshots/review.json"
|
||||
if [[ ! -f "$review_path" ]]; then
|
||||
echo "Missing Playwright review manifest at $review_path." >&2
|
||||
exit 1
|
||||
fi
|
||||
changed_paths_file="$(mktemp)"
|
||||
gh api --paginate \
|
||||
"repos/${GITHUB_REPOSITORY}/pulls/${PR_NUMBER}/files?per_page=100" \
|
||||
--jq '.[].filename' > "$changed_paths_file"
|
||||
body="$(
|
||||
PLAYWRIGHT_GALLERY_URL="$gallery_url" \
|
||||
PLAYWRIGHT_DASHBOARD_URL="$public_base_url/index.html" \
|
||||
PLAYWRIGHT_RUN_URL="$RUN_URL" \
|
||||
PLAYWRIGHT_SHA="$SHA" \
|
||||
pnpm exec tsx packages/testkit/src/cli/build-playwright-pr-screenshot-comment.ts \
|
||||
"$review_path" \
|
||||
"$changed_paths_file"
|
||||
)"
|
||||
marker="<!-- rakazo-playwright-screenshots -->"
|
||||
comment_id="$(gh api --paginate --slurp \
|
||||
"repos/${GITHUB_REPOSITORY}/issues/${PR_NUMBER}/comments?per_page=100" | \
|
||||
jq -r --arg marker "$marker" \
|
||||
'[.[][] | select(.user.login == "github-actions[bot]" and (.body | contains($marker)))] | last | .id // empty')"
|
||||
payload="$(jq -n --arg body "$body" '{body: $body}')"
|
||||
if [[ -n "$comment_id" ]]; then
|
||||
gh api --method PATCH "repos/${GITHUB_REPOSITORY}/issues/comments/${comment_id}" --input - <<<"$payload"
|
||||
else
|
||||
gh api --method POST "repos/${GITHUB_REPOSITORY}/issues/${PR_NUMBER}/comments" --input - <<<"$payload"
|
||||
fi
|
||||
|
|
@ -0,0 +1,153 @@
|
|||
name: publish-server-image
|
||||
|
||||
# Release tags publish the source-addressed image used by in-app updates plus the human-readable
|
||||
# semver tags. Main publishes `edge`. Pull requests build with a read-only token and an isolated
|
||||
# cache, so untrusted code can validate the images without receiving package-publish authority.
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
tags: ["v*"]
|
||||
pull_request:
|
||||
paths:
|
||||
- ".dockerignore"
|
||||
- ".github/workflows/publish-server-image.yml"
|
||||
- "apps/**"
|
||||
- "infra/compose/**"
|
||||
- "infra/sandboxes/computer/**"
|
||||
- "infra/updater/**"
|
||||
- "packages/**"
|
||||
- "package.json"
|
||||
- "pnpm-lock.yaml"
|
||||
- "pnpm-workspace.yaml"
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
env:
|
||||
# docker/metadata-action otherwise truncates commit tags to seven collision-prone characters.
|
||||
DOCKER_METADATA_SHORT_SHA_LENGTH: 40
|
||||
|
||||
concurrency:
|
||||
group: publish-server-image-${{ github.ref }}
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
validate:
|
||||
name: Validate ${{ matrix.name }} image
|
||||
if: github.event_name == 'pull_request'
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 90
|
||||
permissions:
|
||||
contents: read
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- name: app
|
||||
dockerfile: infra/compose/Dockerfile
|
||||
context: .
|
||||
- name: updater
|
||||
dockerfile: infra/updater/Dockerfile
|
||||
context: .
|
||||
- name: computer
|
||||
dockerfile: infra/sandboxes/computer/Dockerfile
|
||||
context: infra/sandboxes/computer
|
||||
steps:
|
||||
- uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5.1.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0
|
||||
- id: meta
|
||||
uses: docker/metadata-action@c299e40c65443455700f0fdfc63efafe5b349051 # v5.10.0
|
||||
with:
|
||||
images: ghcr.io/${{ github.repository }}/${{ matrix.name }}
|
||||
tags: type=sha,prefix=sha-
|
||||
- uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6.19.2
|
||||
with:
|
||||
context: ${{ matrix.context }}
|
||||
file: ${{ matrix.dockerfile }}
|
||||
push: false
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
build-args: ${{ matrix.name == 'app' && format('GIT_SHA={0}', github.sha) || '' }}
|
||||
cache-from: type=gha,scope=${{ matrix.name }}
|
||||
cache-to: type=gha,mode=max,scope=pr-${{ github.event.pull_request.number }}-${{ matrix.name }}
|
||||
provenance: false
|
||||
sbom: false
|
||||
|
||||
publish:
|
||||
name: Publish ${{ matrix.name }} image
|
||||
if: github.event_name != 'pull_request'
|
||||
runs-on: ubuntu-latest
|
||||
# QEMU arm64 is slow; keep everyday main→edge publishes at 90m. Multi-arch
|
||||
# (v* tags + workflow_dispatch) needs the longer budget.
|
||||
timeout-minutes: ${{ (github.event_name == 'workflow_dispatch' || startsWith(github.ref, 'refs/tags/v')) && 180 || 90 }}
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
id-token: write
|
||||
attestations: write
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
# Only the updater image contains the Docker CLI; the application image stays unprivileged.
|
||||
# The supervisor is not a separate published image: it runs from `app` on the internal network.
|
||||
- name: app
|
||||
dockerfile: infra/compose/Dockerfile
|
||||
context: .
|
||||
- name: updater
|
||||
dockerfile: infra/updater/Dockerfile
|
||||
context: .
|
||||
- name: computer
|
||||
dockerfile: infra/sandboxes/computer/Dockerfile
|
||||
context: infra/sandboxes/computer
|
||||
steps:
|
||||
- uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5.1.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
# Emulated arm64 only for releases and manual runs — not the ~10x/day main merge train.
|
||||
- if: github.event_name == 'workflow_dispatch' || startsWith(github.ref, 'refs/tags/v')
|
||||
uses: docker/setup-qemu-action@96fe6ef7f33517b61c61be40b68a1882f3264fb8 # v4.2.0
|
||||
- uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0
|
||||
- name: Log in to GHCR
|
||||
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
- id: meta
|
||||
uses: docker/metadata-action@c299e40c65443455700f0fdfc63efafe5b349051 # v5.10.0
|
||||
with:
|
||||
# A fork can publish only to its own namespace; no credential can redirect this value.
|
||||
images: ghcr.io/${{ github.repository }}/${{ matrix.name }}
|
||||
tags: |
|
||||
type=semver,pattern=v{{version}}
|
||||
type=semver,pattern=v{{major}}.{{minor}}
|
||||
type=sha,prefix=sha-
|
||||
type=raw,value=edge,enable=${{ github.ref == 'refs/heads/main' }}
|
||||
# Stable releases only: a hyphen marks a prerelease (v1.0.0-rc.1) that must not move latest.
|
||||
type=raw,value=latest,enable=${{ startsWith(github.ref, 'refs/tags/v') && !contains(github.ref, '-') }}
|
||||
- id: build
|
||||
uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6.19.2
|
||||
with:
|
||||
context: ${{ matrix.context }}
|
||||
file: ${{ matrix.dockerfile }}
|
||||
push: true
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
# Main→edge stays native amd64. Tags and workflow_dispatch publish amd64+arm64.
|
||||
platforms: ${{ (github.event_name == 'workflow_dispatch' || startsWith(github.ref, 'refs/tags/v')) && 'linux/amd64,linux/arm64' || 'linux/amd64' }}
|
||||
# GET /health can report the exact source commit without a deployment-supplied override.
|
||||
build-args: ${{ matrix.name == 'app' && format('GIT_SHA={0}', github.sha) || '' }}
|
||||
cache-from: type=gha,scope=${{ matrix.name }}
|
||||
cache-to: type=gha,mode=max,scope=${{ matrix.name }}
|
||||
provenance: mode=max
|
||||
sbom: true
|
||||
- name: Attest the published image
|
||||
uses: actions/attest-build-provenance@4d101475d8b20a2381f78447822ac1eab6504dd8 # v4.2.2
|
||||
with:
|
||||
subject-name: ghcr.io/${{ github.repository }}/${{ matrix.name }}
|
||||
subject-digest: ${{ steps.build.outputs.digest }}
|
||||
push-to-registry: true
|
||||
|
|
@ -0,0 +1,324 @@
|
|||
name: release-desktop
|
||||
|
||||
on:
|
||||
push:
|
||||
tags: ["v*"]
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
# Stable releases must validate and publish serially. Per-tag concurrency would let an older
|
||||
# release validate before a newer one publishes, then overwrite GitHub's "latest" pointer.
|
||||
group: release-desktop-stable
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
validate:
|
||||
name: Validate stable release
|
||||
runs-on: ubuntu-24.04
|
||||
outputs:
|
||||
tag: ${{ steps.release.outputs.tag }}
|
||||
version: ${{ steps.release.outputs.version }}
|
||||
sha: ${{ steps.release.outputs.sha }}
|
||||
steps:
|
||||
- uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5
|
||||
with:
|
||||
fetch-depth: 0
|
||||
persist-credentials: false
|
||||
- name: Validate tag, version, ancestry, and monotonicity
|
||||
id: release
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
RELEASE_TAG: ${{ github.ref_name }}
|
||||
RELEASE_REF_TYPE: ${{ github.ref_type }}
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [[ "$RELEASE_REF_TYPE" != "tag" ]]; then
|
||||
echo "Desktop releases must run from a tag, including manual dispatches." >&2
|
||||
exit 1
|
||||
fi
|
||||
if [[ ! "$RELEASE_TAG" =~ ^v([0-9]+)\.([0-9]+)\.([0-9]+)$ ]]; then
|
||||
echo "Desktop releases use the stable vMAJOR.MINOR.PATCH channel." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
version="$(node -p "require('./apps/desktop/package.json').version")"
|
||||
if [[ "$RELEASE_TAG" != "v$version" ]]; then
|
||||
echo "Tag $RELEASE_TAG does not match desktop version $version." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
tag_sha="$(git rev-parse "${RELEASE_TAG}^{commit}")"
|
||||
if [[ "$tag_sha" != "$GITHUB_SHA" ]]; then
|
||||
echo "The workflow revision does not match the tagged commit." >&2
|
||||
exit 1
|
||||
fi
|
||||
git fetch --no-tags origin main
|
||||
if ! git merge-base --is-ancestor "$tag_sha" origin/main; then
|
||||
echo "Desktop releases must point to a commit already on main." >&2
|
||||
exit 1
|
||||
fi
|
||||
if gh api "repos/${GITHUB_REPOSITORY}/releases/tags/${RELEASE_TAG}" >/dev/null 2>&1; then
|
||||
echo "A release already exists for $RELEASE_TAG; refusing to replace it." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
latest_tag="$(
|
||||
gh api --paginate "repos/${GITHUB_REPOSITORY}/releases?per_page=100" --jq '.[] | select(.draft == false and .prerelease == false) | .tag_name' |
|
||||
sed -nE '/^v[0-9]+\.[0-9]+\.[0-9]+$/p' |
|
||||
sort -V |
|
||||
tail -n 1
|
||||
)"
|
||||
if [[ -n "$latest_tag" ]]; then
|
||||
highest="$(printf '%s\n%s\n' "$latest_tag" "$RELEASE_TAG" | sort -V | tail -n 1)"
|
||||
if [[ "$highest" != "$RELEASE_TAG" || "$latest_tag" == "$RELEASE_TAG" ]]; then
|
||||
echo "$RELEASE_TAG must be newer than published release $latest_tag." >&2
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
echo "tag=$RELEASE_TAG" >> "$GITHUB_OUTPUT"
|
||||
echo "version=$version" >> "$GITHUB_OUTPUT"
|
||||
echo "sha=$tag_sha" >> "$GITHUB_OUTPUT"
|
||||
|
||||
build:
|
||||
name: Build signed ${{ matrix.artifact }} artifacts
|
||||
needs: validate
|
||||
runs-on: ${{ matrix.os }}
|
||||
timeout-minutes: 60
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- os: macos-14
|
||||
artifact: macos
|
||||
- os: windows-2022
|
||||
artifact: windows
|
||||
- os: ubuntu-24.04
|
||||
artifact: linux
|
||||
steps:
|
||||
- uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5
|
||||
with:
|
||||
ref: ${{ needs.validate.outputs.sha }}
|
||||
persist-credentials: false
|
||||
- uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4
|
||||
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
|
||||
with:
|
||||
node-version: 22
|
||||
- run: pnpm install --frozen-lockfile
|
||||
- name: Build renderer and Electron main process
|
||||
run: pnpm --filter @rakazo/web build && pnpm --filter @rakazo/desktop build
|
||||
env:
|
||||
RAKAZO_ALLOW_DEV_SECRETS: "1"
|
||||
|
||||
- name: Require macOS signing and notarization credentials
|
||||
if: runner.os == 'macOS'
|
||||
shell: bash
|
||||
env:
|
||||
CSC_LINK: ${{ secrets.DESKTOP_MAC_CSC_LINK }}
|
||||
CSC_KEY_PASSWORD: ${{ secrets.DESKTOP_MAC_CSC_KEY_PASSWORD }}
|
||||
APPLE_ID: ${{ secrets.APPLE_ID }}
|
||||
APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }}
|
||||
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
test -n "$CSC_LINK"
|
||||
test -n "$CSC_KEY_PASSWORD"
|
||||
test -n "$APPLE_ID"
|
||||
test -n "$APPLE_APP_SPECIFIC_PASSWORD"
|
||||
test -n "$APPLE_TEAM_ID"
|
||||
- name: Package signed and notarized universal macOS app
|
||||
if: runner.os == 'macOS'
|
||||
env:
|
||||
CSC_LINK: ${{ secrets.DESKTOP_MAC_CSC_LINK }}
|
||||
CSC_KEY_PASSWORD: ${{ secrets.DESKTOP_MAC_CSC_KEY_PASSWORD }}
|
||||
APPLE_ID: ${{ secrets.APPLE_ID }}
|
||||
APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }}
|
||||
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
|
||||
run: >-
|
||||
pnpm --filter @rakazo/desktop exec electron-builder
|
||||
--mac --universal --publish never -c.forceCodeSigning=true
|
||||
- name: Verify macOS signature, notarization ticket, and update feed
|
||||
if: runner.os == 'macOS'
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
app="apps/desktop/out/mac-universal/Rakazo.app"
|
||||
codesign --verify --deep --strict --verbose=2 "$app"
|
||||
xcrun stapler validate "$app"
|
||||
spctl --assess --type execute --verbose=2 "$app"
|
||||
grep -Fqx "provider: github" "$app/Contents/Resources/app-update.yml"
|
||||
grep -Fqx "owner: elie222" "$app/Contents/Resources/app-update.yml"
|
||||
grep -Fqx "repo: rakazo" "$app/Contents/Resources/app-update.yml"
|
||||
|
||||
- name: Require Windows signing credentials
|
||||
if: runner.os == 'Windows'
|
||||
shell: bash
|
||||
env:
|
||||
WIN_CSC_LINK: ${{ secrets.DESKTOP_WIN_CSC_LINK }}
|
||||
WIN_CSC_KEY_PASSWORD: ${{ secrets.DESKTOP_WIN_CSC_KEY_PASSWORD }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
test -n "$WIN_CSC_LINK"
|
||||
test -n "$WIN_CSC_KEY_PASSWORD"
|
||||
- name: Package signed x64 Windows app
|
||||
if: runner.os == 'Windows'
|
||||
env:
|
||||
WIN_CSC_LINK: ${{ secrets.DESKTOP_WIN_CSC_LINK }}
|
||||
WIN_CSC_KEY_PASSWORD: ${{ secrets.DESKTOP_WIN_CSC_KEY_PASSWORD }}
|
||||
run: >-
|
||||
pnpm --filter @rakazo/desktop exec electron-builder
|
||||
--win --x64 --publish never -c.forceCodeSigning=true
|
||||
- name: Verify Windows Authenticode signature and publisher-bound update feed
|
||||
if: runner.os == 'Windows'
|
||||
shell: pwsh
|
||||
run: |
|
||||
$ErrorActionPreference = "Stop"
|
||||
$executables = @(
|
||||
Get-ChildItem "apps/desktop/out/*.exe"
|
||||
Get-ChildItem "apps/desktop/out/win-unpacked/*.exe"
|
||||
)
|
||||
if ($executables.Count -lt 2) { throw "Windows installer or application was not created." }
|
||||
foreach ($executable in $executables) {
|
||||
$signature = Get-AuthenticodeSignature $executable.FullName
|
||||
if ($signature.Status -ne "Valid") {
|
||||
throw "$($executable.Name) signature is $($signature.Status)."
|
||||
}
|
||||
}
|
||||
$config = "apps/desktop/out/win-unpacked/resources/app-update.yml"
|
||||
if (-not (Select-String -Path $config -Pattern '^publisherName:' -Quiet)) {
|
||||
throw "Windows update config is not bound to the signing publisher."
|
||||
}
|
||||
$feed = Get-Content -Raw $config
|
||||
foreach ($expected in @("provider: github", "owner: elie222", "repo: rakazo")) {
|
||||
if ($feed -notmatch "(?m)^$([regex]::Escape($expected))\r?$") {
|
||||
throw "Windows update config missing '$expected'."
|
||||
}
|
||||
}
|
||||
|
||||
- name: Package x64 Linux AppImage
|
||||
if: runner.os == 'Linux'
|
||||
run: >-
|
||||
pnpm --filter @rakazo/desktop exec electron-builder
|
||||
--linux --x64 --publish never
|
||||
- name: Verify Linux update feed is pinned to the official GitHub channel
|
||||
if: runner.os == 'Linux'
|
||||
shell: bash
|
||||
env:
|
||||
RELEASE_VERSION: ${{ needs.validate.outputs.version }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
config="apps/desktop/out/linux-unpacked/resources/app-update.yml"
|
||||
if [[ ! -f "$config" ]]; then
|
||||
config="$(find apps/desktop/out -name app-update.yml -print -quit)"
|
||||
fi
|
||||
test -n "$config"
|
||||
test -f "$config"
|
||||
grep -Fqx "provider: github" "$config"
|
||||
grep -Fqx "owner: elie222" "$config"
|
||||
grep -Fqx "repo: rakazo" "$config"
|
||||
test -f apps/desktop/out/latest-linux.yml
|
||||
grep -Fqx "version: $RELEASE_VERSION" apps/desktop/out/latest-linux.yml
|
||||
|
||||
- name: Retain installers and updater metadata
|
||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
|
||||
with:
|
||||
name: desktop-${{ matrix.artifact }}-${{ needs.validate.outputs.sha }}
|
||||
path: |
|
||||
apps/desktop/out/*.AppImage
|
||||
apps/desktop/out/*.blockmap
|
||||
apps/desktop/out/*.dmg
|
||||
apps/desktop/out/*.exe
|
||||
apps/desktop/out/latest*.yml
|
||||
apps/desktop/out/*.zip
|
||||
if-no-files-found: error
|
||||
compression-level: 0
|
||||
retention-days: 7
|
||||
|
||||
publish:
|
||||
name: Attest and publish complete release
|
||||
needs: [validate, build]
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 15
|
||||
permissions:
|
||||
actions: read
|
||||
attestations: write
|
||||
contents: write
|
||||
id-token: write
|
||||
steps:
|
||||
- uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5
|
||||
with:
|
||||
fetch-depth: 0
|
||||
persist-credentials: false
|
||||
ref: ${{ needs.validate.outputs.sha }}
|
||||
- uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4
|
||||
with:
|
||||
pattern: desktop-*-${{ needs.validate.outputs.sha }}
|
||||
path: release-artifacts
|
||||
merge-multiple: true
|
||||
- name: Verify the complete stable update feed
|
||||
env:
|
||||
RELEASE_SHA: ${{ needs.validate.outputs.sha }}
|
||||
RELEASE_TAG: ${{ needs.validate.outputs.tag }}
|
||||
RELEASE_VERSION: ${{ needs.validate.outputs.version }}
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
git fetch --force origin "refs/tags/${RELEASE_TAG}:refs/tags/${RELEASE_TAG}"
|
||||
if [[ "$(git rev-parse "${RELEASE_TAG}^{commit}")" != "$RELEASE_SHA" ]]; then
|
||||
echo "The release tag moved after the build started." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
test -f release-artifacts/latest.yml
|
||||
test -f release-artifacts/latest-mac.yml
|
||||
test -f release-artifacts/latest-linux.yml
|
||||
compgen -G 'release-artifacts/*.dmg' >/dev/null
|
||||
compgen -G 'release-artifacts/*.zip' >/dev/null
|
||||
compgen -G 'release-artifacts/*.exe' >/dev/null
|
||||
compgen -G 'release-artifacts/*.AppImage' >/dev/null
|
||||
feeds=(
|
||||
release-artifacts/latest.yml
|
||||
release-artifacts/latest-mac.yml
|
||||
release-artifacts/latest-linux.yml
|
||||
)
|
||||
for feed in "${feeds[@]}"; do
|
||||
grep -Fqx "version: $RELEASE_VERSION" "$feed"
|
||||
done
|
||||
|
||||
(
|
||||
cd release-artifacts
|
||||
find . -maxdepth 1 -type f ! -name SHA256SUMS -print0 |
|
||||
sort -z |
|
||||
xargs -0 sha256sum > SHA256SUMS
|
||||
)
|
||||
- name: Attest release provenance
|
||||
uses: actions/attest-build-provenance@977bb373ede98d70efdf65b84cb5f73e068dcc2a # v3
|
||||
with:
|
||||
subject-path: release-artifacts/*
|
||||
- name: Create draft and upload every platform
|
||||
id: create_release
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
RELEASE_TAG: ${{ needs.validate.outputs.tag }}
|
||||
run: >-
|
||||
gh release create "$RELEASE_TAG" release-artifacts/*
|
||||
--draft --generate-notes --title "Rakazo $RELEASE_TAG" --verify-tag
|
||||
- name: Publish the completed release
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
RELEASE_TAG: ${{ needs.validate.outputs.tag }}
|
||||
run: gh release edit "$RELEASE_TAG" --draft=false --latest
|
||||
- name: Remove an incomplete draft
|
||||
if: failure() && steps.create_release.outcome != 'skipped'
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
RELEASE_TAG: ${{ needs.validate.outputs.tag }}
|
||||
shell: bash
|
||||
run: |
|
||||
if [[ "$(gh api "repos/${GITHUB_REPOSITORY}/releases/tags/${RELEASE_TAG}" --jq .draft 2>/dev/null)" == "true" ]]; then
|
||||
gh release delete "$RELEASE_TAG" --yes
|
||||
fi
|
||||
|
|
@ -0,0 +1,45 @@
|
|||
.deepsec/
|
||||
node_modules
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
.turbo
|
||||
dist
|
||||
build
|
||||
coverage
|
||||
.playwright-report
|
||||
test-results
|
||||
playwright-report
|
||||
*.tsbuildinfo
|
||||
.env
|
||||
.env.local
|
||||
.env.*.local
|
||||
data
|
||||
apps/**/data
|
||||
.tmp
|
||||
.DS_Store
|
||||
apps/web/dist
|
||||
apps/desktop/dist
|
||||
apps/desktop/out
|
||||
apps/desktop/e2e/screenshots
|
||||
apps/mobile/.expo
|
||||
apps/mobile/dist
|
||||
apps/mobile/ios
|
||||
packages/db/src/generated
|
||||
infra/sandboxes/supervisor/dist
|
||||
*.log
|
||||
.vercel
|
||||
.cache
|
||||
artifacts
|
||||
backups
|
||||
test-report
|
||||
verify-report
|
||||
.astro
|
||||
.deploy.lock
|
||||
.last-deployed-revision
|
||||
design/
|
||||
PRODUCT_PLAN.md
|
||||
|
||||
# Lingui compiled catalogs — Vite plugin compiles .po at build time
|
||||
apps/web/src/locales/**/*.mjs
|
||||
apps/web/src/locales/**/*.js
|
||||
apps/web/scripts/msgids.json
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
engine-strict=true
|
||||
strict-peer-dependencies=false
|
||||
auto-install-peers=true
|
||||
|
|
@ -0,0 +1,11 @@
|
|||
# AGENTS.md
|
||||
|
||||
- This is a public repository: assume all tracked content and diffs are public. Never commit secrets, `.env` files, private URLs, personal/customer data, or real production data; use fake placeholders. Review `git status` and the staged diff before committing, and never force-add ignored files. If private data appears, stop and alert the maintainer.
|
||||
- BangSo Bot is one product across web, Electron desktop, and Expo mobile; Electron hosts the web UI. Put shared behavior, contracts, API logic, and reusable UI in packages. Keep only genuinely native navigation, storage, permissions, and interactions platform-specific. Core workflows must cover every applicable surface or degrade safely for an explicit reason.
|
||||
- No hosted vendor is required to run the core product. Keep LLMs, sandboxes, memory, voice, integrations, and future external services optional and behind provider-neutral interfaces. Vendor SDKs, configuration, and translation belong only in adapters and composition roots. New providers must reuse shared contracts and deterministic offline conformance tests.
|
||||
- Keep UI and copy minimal. Show advanced capability progressively and only when it becomes relevant; do not add explainer text that repeats the interface. Frontends express intent and render state; the backend owns orchestration, authorization, validation, retries, recovery, and provider translation. Give controls concise accessible names when needed.
|
||||
- Keep code simple: reuse existing primitives and one source of truth, remove duplication and unused flexibility, and avoid speculative abstractions. Add an interface when it protects a real external or platform boundary, not for its own sake.
|
||||
- Treat auth, secret handling, sandbox boundaries, host commands, and integrations as security-sensitive. Keep tests deterministic and offline by default.
|
||||
- After creating a pull request, stay with it until CI and automated review bots have finished. Poll checks, reviews, review threads, and PR comments at roughly 60-second intervals; passing checks alone do not mean the review is complete. Address every actionable issue, push the fixes, and repeat the review cycle until no actionable feedback remains. Do not merge while review bots are still pending or review issues remain unresolved.
|
||||
- For UI changes, link the CI E2E screenshot that shows the change on the PR; add the web test that opens that screen if it is missing. For native-only mobile UI that CI cannot capture, say so in the PR instead of linking an unrelated web screenshot.
|
||||
- For UI work, check Beautiful UI first (https://www.beautifului.dev/, source: github.com/TurboKach/ai-native-react-components, MIT) before building anything by hand. It names the AI-native surfaces this product needs (loading/thinking states, streaming text, approval cards, tool chips, task rows, composer, tables). Hand-port from source into `apps/web/src/components/beautiful-ui/` — read the component and adapt its tokens; never `shadcn add` from the live registry (supply-chain risk). Reuse the ported primitives (`LoadingState`, `Shimmer`, `SuccessPop`, `BuiCard`, `BuiButton`) before inventing new ones.
|
||||
|
|
@ -0,0 +1,32 @@
|
|||
# Changelog
|
||||
|
||||
Notable product changes in Rakazo. This is for people following the repo, not a dump of every commit. GitHub Releases still mark tagged builds.
|
||||
|
||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Changed
|
||||
|
||||
- The phone surface became a multi-platform messaging surface built on the open-source Chat SDK. Slack, WhatsApp Business Cloud, and Telegram DMs now work alongside iMessage/SMS (Sendblue). Link a chat app to your account from Messaging settings on the web: pick a bot, send the short-lived code to the line, and that conversation reaches that bot — each app can point at a different bot. Unknown senders are ignored unless `MESSAGING_OPEN_SIGNUP=true`, which restores the old text-first auto-provisioning (and is the only mode that needs the deployment model key). Webhooks move to `/api/v1/messaging/webhook/<provider>` (the old Sendblue path still works), and each platform mounts when its env credentials are set — see `.env.example`. Group channels remain iMessage-only for now; other platforms are 1:1 until their channel semantics are mapped.
|
||||
- Model picker includes Grok 4.6 (xAI) and Ox Alpha Free / GLM-5.3 (OpenCode Go).
|
||||
|
||||
### Added
|
||||
|
||||
- Voice mode: speak replies, hold-to-talk dictation, and half-duplex calls. Speech sits behind a `VoiceProvider` interface (ElevenLabs, OpenAI, Cartesia) so the product is not tied to one vendor. Keys stay on the server.
|
||||
- Electron first-run: Docker (default) or this Mac. This Mac runs the bot shell as you, with working directories under your home folder. macOS does not show its own permission dialog; the consent is Rakazo's. The choice is owner-only and is refused when `SANDBOX_PROVIDER` is not `docker` (so E2B and test fakes cannot enable it).
|
||||
- GitHub Copilot and SuperGrok / X Premium sign-in via Pi device-code OAuth (`openai-codex`, `github-copilot`, `xai`). Claude Pro is still omitted because Pi's Claude login uses a localhost callback that does not work from the web app.
|
||||
- Spawn peer bots (each with its own thread and computer) and short-lived in-thread subagents.
|
||||
- ChatGPT Plus or Pro sign-in for model access.
|
||||
- Mobile: point the app at a self-hosted API origin, a native iOS inbox, and take control of the live desktop.
|
||||
- Provider-neutral integrations: managed apps through Composio or Pipedream Connect, plus encrypted user-installed Treg, HTTPS MCP, and OpenAPI tool sources on web and mobile.
|
||||
- Revoke for connected Composio plugins.
|
||||
- Routines in plain language instead of raw cron.
|
||||
|
||||
### Removed
|
||||
|
||||
- Unused Grant folder picker in the desktop app. Bots never got a host folder that way.
|
||||
|
||||
## [0.1.0-beta] - 2026-08-13
|
||||
|
||||
Initial public beta: web, Electron, and Expo clients; Pi runtime; Docker and E2B computers; plugins; one thread, computer, memory, routines, and history per bot.
|
||||
|
|
@ -0,0 +1,61 @@
|
|||
# Contributing to Rakazo
|
||||
|
||||
Thanks for helping improve Rakazo. Keep changes focused and testable.
|
||||
|
||||
## Run locally
|
||||
|
||||
See [README.md](README.md) for full details. Quick start from the repo root:
|
||||
|
||||
```bash
|
||||
cp .env.example .env
|
||||
# Set BETTER_AUTH_SECRET and ENCRYPTION_KEY to long random strings.
|
||||
docker compose --env-file .env -f infra/compose/docker-compose.yml up postgres -d
|
||||
pnpm install
|
||||
pnpm db:generate
|
||||
pnpm db:migrate
|
||||
pnpm sandbox:build
|
||||
pnpm dev
|
||||
```
|
||||
|
||||
## Checks before you open a PR
|
||||
|
||||
| Command | When to run |
|
||||
| --- | --- |
|
||||
| `pnpm test` | Default. Units, properties, and in-process contracts. Scripted runtime, fake sandbox, in-memory wakeup — no live connector or model-provider calls. |
|
||||
| `pnpm test:integration` | Postgres via Testcontainers: product journeys, authorization, executor lifecycle, Graphile / LISTEN/NOTIFY. Needs Docker. |
|
||||
| `pnpm test:e2e` | Playwright against the emulated API. Needs Docker. |
|
||||
| `pnpm test:topology` | Local product-path smoke: Docker computer + Graphile worker recovery. Needs Docker. Not PR CI. |
|
||||
| `pnpm test:canary` | Live OpenRouter / E2B canaries. Needs keys. Not PR CI. |
|
||||
| `pnpm test:computer` | Real vision model + E2B desktop. Needs keys; see README. Not PR CI. |
|
||||
| `pnpm check` | TypeScript (`tsc`) across the monorepo. |
|
||||
| `pnpm lint` | Biome lint and format check. |
|
||||
|
||||
CI runs `pnpm lint`, `pnpm check`, production builds (including Electron preload smoke), `pnpm test`, `pnpm test:integration`, and `pnpm test:e2e` on every PR.
|
||||
|
||||
## Secrets and configuration
|
||||
|
||||
- **Never** commit `.env` files or secrets.
|
||||
- **Never** paste API keys, tokens, or passwords in issues or PRs.
|
||||
- Use placeholders in examples (`your-openrouter-key`, etc.).
|
||||
|
||||
The product path is **Pi + Docker + Graphile**. Emulator settings (`AGENT_RUNTIME=scripted`, `SANDBOX_PROVIDER=fake`, `WAKEUP_DRIVER=memory`) are for tests only.
|
||||
|
||||
**Integrations** can use [Composio](https://composio.dev/) or Pipedream Connect as optional managed
|
||||
app catalogs. Users can also install HTTPS MCP servers (including Treg) and bounded OpenAPI tool
|
||||
sources. Connector tests must stay deterministic and offline. Never put connector credentials in
|
||||
capability config, fixtures, logs, or snapshots; use the encrypted secret store and fake placeholders.
|
||||
|
||||
## Pull requests
|
||||
|
||||
- Keep PRs small and easy to review.
|
||||
- Target the `main` branch.
|
||||
- Describe what changed and **how you tested** (e.g. `pnpm test`, manual steps).
|
||||
- Link related issues when applicable.
|
||||
|
||||
## Contact
|
||||
|
||||
| Address | Use for |
|
||||
| --- | --- |
|
||||
| [security@rakazo.com](mailto:security@rakazo.com) | Vulnerabilities only — see [SECURITY.md](SECURITY.md) |
|
||||
| [support@rakazo.com](mailto:support@rakazo.com) | User and support questions |
|
||||
| [elie@rakazo.com](mailto:elie@rakazo.com) | Maintainer |
|
||||
|
|
@ -0,0 +1,178 @@
|
|||
Apache License
|
||||
Version 2.0, January 2004
|
||||
http://www.apache.org/licenses/
|
||||
|
||||
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
||||
|
||||
1. Definitions.
|
||||
|
||||
"License" shall mean the terms and conditions for use, reproduction, and
|
||||
distribution as defined by Sections 1 through 9 of this document.
|
||||
|
||||
"Licensor" shall mean the copyright owner or entity authorized by the
|
||||
copyright owner that is granting the License.
|
||||
|
||||
"Legal Entity" shall mean the union of the acting entity and all other
|
||||
entities that control, are controlled by, or are under common control with
|
||||
that entity. For the purposes of this definition, "control" means (i) the
|
||||
power, direct or indirect, to cause the direction or management of such
|
||||
entity, whether by contract or otherwise, or (ii) ownership of fifty percent
|
||||
(50%) or more of the outstanding shares, or (iii) beneficial ownership of
|
||||
such entity.
|
||||
|
||||
"You" (or "Your") shall mean an individual or Legal Entity exercising
|
||||
permissions granted by this License.
|
||||
|
||||
"Source" form shall mean the preferred form for making modifications,
|
||||
including but not limited to software source code, documentation source, and
|
||||
configuration files.
|
||||
|
||||
"Object" form shall mean any form resulting from mechanical transformation or
|
||||
translation of a Source form, including but not limited to compiled object
|
||||
code, generated documentation, and conversions to other media types.
|
||||
|
||||
"Work" shall mean the work of authorship, whether in Source or Object form,
|
||||
made available under the License, as indicated by a copyright notice that is
|
||||
included in or attached to the work (an example is provided in the Appendix
|
||||
below).
|
||||
|
||||
"Derivative Works" shall mean any work, whether in Source or Object form,
|
||||
that is based on (or derived from) the Work and for which the editorial
|
||||
revisions, annotations, elaborations, or other modifications represent, as a
|
||||
whole, an original work of authorship. For the purposes of this License,
|
||||
Derivative Works shall not include works that remain separable from, or
|
||||
merely link (or bind by name) to the interfaces of, the Work and Derivative
|
||||
Works thereof.
|
||||
|
||||
"Contribution" shall mean any work of authorship, including the original
|
||||
version of the Work and any modifications or additions to that Work or
|
||||
Derivative Works thereof, that is intentionally submitted to Licensor for
|
||||
inclusion in the Work by the copyright owner or by an individual or Legal
|
||||
Entity authorized to submit on behalf of the copyright owner. For the
|
||||
purposes of this definition, "submitted" means any form of electronic,
|
||||
verbal, or written communication sent to the Licensor or its representatives,
|
||||
including but not limited to communication on electronic mailing lists,
|
||||
source code control systems, and issue tracking systems that are managed by,
|
||||
or on behalf of, the Licensor for the purpose of discussing and improving
|
||||
the Work, but excluding communication that is conspicuously marked or
|
||||
otherwise designated in writing by the copyright owner as "Not a Contribution."
|
||||
|
||||
"Contributor" shall mean Licensor and any individual or Legal Entity on
|
||||
behalf of whom a Contribution has been received by Licensor and subsequently
|
||||
incorporated within the Work.
|
||||
|
||||
2. Grant of Copyright License. Subject to the terms and conditions of this
|
||||
License, each Contributor hereby grants to You a perpetual, worldwide,
|
||||
non-exclusive, no-charge, royalty-free, irrevocable copyright license to
|
||||
reproduce, prepare Derivative Works of, publicly display, publicly perform,
|
||||
sublicense, and distribute the Work and such Derivative Works in Source or
|
||||
Object form.
|
||||
|
||||
3. Grant of Patent License. Subject to the terms and conditions of this
|
||||
License, each Contributor hereby grants to You a perpetual, worldwide,
|
||||
non-exclusive, no-charge, royalty-free, irrevocable (except as stated in
|
||||
this section) patent license to make, have made, use, offer to sell, sell,
|
||||
import, and otherwise transfer the Work, where such license applies only to
|
||||
those patent claims licensable by such Contributor that are necessarily
|
||||
infringed by their Contribution(s) alone or by combination of their
|
||||
Contribution(s) with the Work to which such Contribution(s) was submitted.
|
||||
If You institute patent litigation against any entity (including a
|
||||
cross-claim or counterclaim in a lawsuit) alleging that the Work or a
|
||||
Contribution incorporated within the Work constitutes direct or contributory
|
||||
patent infringement, then any patent licenses granted to You under this
|
||||
License for that Work shall terminate as of the date such litigation is
|
||||
filed.
|
||||
|
||||
4. Redistribution. You may reproduce and distribute copies of the Work or
|
||||
Derivative Works thereof in any medium, with or without modifications, and
|
||||
in Source or Object form, provided that You meet the following conditions:
|
||||
|
||||
(a) You must give any other recipients of the Work or Derivative Works a
|
||||
copy of this License; and
|
||||
|
||||
(b) You must cause any modified files to carry prominent notices stating
|
||||
that You changed the files; and
|
||||
|
||||
(c) You must retain, in the Source form of any Derivative Works that You
|
||||
distribute, all copyright, patent, trademark, and attribution notices from
|
||||
the Source form of the Work, excluding those notices that do not pertain to
|
||||
any part of the Derivative Works; and
|
||||
|
||||
(d) If the Work includes a "NOTICE" text file as part of its distribution,
|
||||
then any Derivative Works that You distribute must include a readable copy
|
||||
of the attribution notices contained within such NOTICE file, excluding
|
||||
those notices that do not pertain to any part of the Derivative Works, in at
|
||||
least one of the following places: within a NOTICE text file distributed as
|
||||
part of the Derivative Works; within the Source form or documentation, if
|
||||
provided along with the Derivative Works; or, within a display generated by
|
||||
the Derivative Works, if and wherever such third-party notices normally
|
||||
appear. The contents of the NOTICE file are for informational purposes only
|
||||
and do not modify the License. You may add Your own attribution notices
|
||||
within Derivative Works that You distribute, alongside or as an addendum to
|
||||
the NOTICE text from the Work, provided that such additional attribution
|
||||
notices cannot be construed as modifying the License.
|
||||
|
||||
You may add Your own copyright statement to Your modifications and may
|
||||
provide additional or different license terms and conditions for use,
|
||||
reproduction, or distribution of Your modifications, or for any such
|
||||
Derivative Works as a whole, provided Your use, reproduction, and
|
||||
distribution of the Work otherwise complies with the conditions stated in
|
||||
this License.
|
||||
|
||||
5. Submission of Contributions. Unless You explicitly state otherwise, any
|
||||
Contribution intentionally submitted for inclusion in the Work by You to the
|
||||
Licensor shall be under the terms and conditions of this License, without
|
||||
any additional terms or conditions. Notwithstanding the above, nothing
|
||||
herein shall supersede or modify the terms of any separate license agreement
|
||||
you may have executed with Licensor regarding such Contributions.
|
||||
|
||||
6. Trademarks. This License does not grant permission to use the trade
|
||||
names, trademarks, service marks, or product names of the Licensor, except
|
||||
as required for reasonable and customary use in describing the origin of the
|
||||
Work and reproducing the content of the NOTICE file.
|
||||
|
||||
7. Disclaimer of Warranty. Unless required by applicable law or agreed to
|
||||
in writing, Licensor provides the Work (and each Contributor provides its
|
||||
Contributions) on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
KIND, either express or implied, including, without limitation, any
|
||||
warranties or conditions of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or
|
||||
FITNESS FOR A PARTICULAR PURPOSE. You are solely responsible for determining
|
||||
the appropriateness of using or redistributing the Work and assume any risks
|
||||
associated with Your exercise of permissions under this License.
|
||||
|
||||
8. Limitation of Liability. In no event and under no legal theory, whether
|
||||
in tort (including negligence), contract, or otherwise, unless required by
|
||||
applicable law (such as deliberate and grossly negligent acts) or agreed to
|
||||
in writing, shall any Contributor be liable to You for damages, including
|
||||
any direct, indirect, special, incidental, or consequential damages of any
|
||||
character arising as a result of this License or out of the use or inability
|
||||
to use the Work (including but not limited to damages for loss of goodwill,
|
||||
work stoppage, computer failure or malfunction, or any and all other
|
||||
commercial damages or losses), even if such Contributor has been advised of
|
||||
the possibility of such damages.
|
||||
|
||||
9. Accepting Warranty or Additional Liability. While redistributing the
|
||||
Work or Derivative Works thereof, You may choose to offer, and charge a fee
|
||||
for, acceptance of support, warranty, indemnity, or other liability
|
||||
obligations and/or rights consistent with this License. However, in
|
||||
accepting such obligations, You may act only on Your own behalf and on Your
|
||||
sole responsibility, not on behalf of any other Contributor, and only if
|
||||
You agree to indemnify, defend, and hold each Contributor harmless for any
|
||||
liability incurred by, or claims asserted against, such Contributor by
|
||||
reason of your accepting any such warranty or additional liability.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
Copyright 2026 Rakazo contributors
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
|
|
@ -0,0 +1,200 @@
|
|||
# BangSo Bot
|
||||
|
||||
[](https://github.com/elie222/rakazo/stargazers)
|
||||
[](https://discord.gg/RWwKa2Sn7h)
|
||||
|
||||

|
||||
|
||||
BangSo Bot is an open-source platform for running persistent AI teammates. It is available on the web,
|
||||
as an Electron desktop app, and through an Expo mobile app. Bring your own model and computer
|
||||
provider, or run the complete stack locally.
|
||||
|
||||
BangSo Bot is in beta. Learn more at [rakazo.com](https://rakazo.com).
|
||||
|
||||
## Features
|
||||
|
||||
- Persistent bots with their own conversations, memory, routines, and history
|
||||
- Voice mode: speak replies, dictate, and call a bot. Bring your own ElevenLabs, OpenAI, or Cartesia key
|
||||
- Shared Team Computers and isolated Private computers
|
||||
- Browser, terminal, file, and graphical desktop access
|
||||
- Bots that can delegate to peer bots or short-lived subagents
|
||||
- Bring-your-own model credentials through Pi
|
||||
- App integrations through Composio or Pipedream Connect, plus user-installed Treg, remote MCP, and OpenAPI tool sources
|
||||
- Docker, E2B, Daytona, Box, and trusted local-computer support
|
||||
|
||||
## Demo
|
||||
|
||||
https://github.com/user-attachments/assets/dccdeddb-2134-4a56-8eed-b2e591736b1c
|
||||
|
||||
## Stack
|
||||
|
||||
- TypeScript
|
||||
- React 19, Vite, and Tailwind CSS
|
||||
- Electron and Expo
|
||||
- Hono and oRPC
|
||||
- PostgreSQL and Prisma
|
||||
- Better Auth
|
||||
- Graphile Worker
|
||||
- Pi
|
||||
- Docker, E2B, Daytona, and Box
|
||||
- Composio, Pipedream Connect, MCP, and OpenAPI integrations
|
||||
|
||||
## Quick start (published images)
|
||||
|
||||
You need Docker Engine, the Compose plugin, curl, and OpenSSL. No clone or Node install.
|
||||
|
||||
```bash
|
||||
mkdir -p rakazo && cd rakazo &&
|
||||
curl -fsSLO https://raw.githubusercontent.com/elie222/rakazo/main/infra/compose/install-images.sh &&
|
||||
bash install-images.sh
|
||||
```
|
||||
|
||||
The installer downloads the Compose files, creates `.env` with random secrets, and starts BangSo Bot.
|
||||
It preserves an existing `.env` when rerun.
|
||||
|
||||
Open [http://127.0.0.1:5173](http://127.0.0.1:5173), create an account, and connect a model.
|
||||
Local Docker computers are on by default. Optional remote providers: `e2b`, `daytona`, or `box`
|
||||
with the matching API key.
|
||||
|
||||
Default image tag is `edge` (main builds, `linux/amd64`). Details and tags:
|
||||
[self-hosting guide](./docs/self-host.md#published-images-no-checkout).
|
||||
|
||||
For an agent-assisted install, use [SETUP_PROMPT.md](./SETUP_PROMPT.md).
|
||||
|
||||
## Local development (source checkout)
|
||||
|
||||
You need Node.js 22+, pnpm 9, and Docker.
|
||||
|
||||
```bash
|
||||
git clone https://github.com/elie222/rakazo.git
|
||||
cd rakazo
|
||||
cp .env.example .env
|
||||
```
|
||||
|
||||
Set `BETTER_AUTH_SECRET`, `ENCRYPTION_KEY`, and `SCREEN_PROXY_SECRET` in `.env` to independent
|
||||
long random values. Docker sandboxes also need a dedicated `SANDBOX_SUPERVISOR_TOKEN`. You can
|
||||
also set `OPENROUTER_API_KEY`, or connect a supported model provider during onboarding.
|
||||
|
||||
Managed app catalogs are optional. Set `COMPOSIO_API_KEY` for Composio, or the
|
||||
`PIPEDREAM_CLIENT_ID`, `PIPEDREAM_CLIENT_SECRET`, and `PIPEDREAM_PROJECT_ID` trio for Pipedream
|
||||
Connect. Users can add an HTTPS MCP server, Treg endpoint, or OpenAPI JSON document from
|
||||
**Integrations** without enabling either managed catalog. Connector credentials are encrypted on the
|
||||
server and are never returned by the API.
|
||||
|
||||
Treg is usage-metered. Self-hosters supply their own Treg token; operators embedding Treg in a
|
||||
hosted product should review [Treg's integration terms](https://treg.to/integrate.md), which require
|
||||
a written agreement for hosted resale.
|
||||
|
||||
```bash
|
||||
docker compose --env-file .env -f infra/compose/docker-compose.yml up postgres -d
|
||||
pnpm install
|
||||
pnpm db:generate
|
||||
pnpm db:migrate
|
||||
pnpm sandbox:build
|
||||
pnpm dev
|
||||
```
|
||||
|
||||
After `.env` is configured, the same local startup sequence is available as:
|
||||
|
||||
```bash
|
||||
pnpm start:local
|
||||
```
|
||||
|
||||
The script checks Docker and required tools, starts Postgres, prepares the database, builds the
|
||||
local computer image when missing, and then starts the API, worker, web app, and sandbox supervisor.
|
||||
|
||||
To run the complete stack in Docker and expose only the web entry point to the local network, run
|
||||
`pnpm start:docker-lan`. The script detects the Mac's LAN IPv4 address and prints the URL to open
|
||||
from another device. API, Postgres, worker, supervisor, and sandbox ports remain private.
|
||||
|
||||
Open [http://127.0.0.1:5173](http://127.0.0.1:5173), create an account, connect a model, and create
|
||||
your first bot.
|
||||
|
||||
For deployment, provider selection, backups, and upgrades, see the
|
||||
[self-hosting guide](./docs/self-host.md).
|
||||
|
||||
## Desktop and mobile
|
||||
|
||||
The Electron and Expo apps are clients of the same BangSo Bot API used by the web app.
|
||||
|
||||
With the development stack running, launch Electron with:
|
||||
|
||||
```bash
|
||||
pnpm --filter @rakazo/desktop dev
|
||||
```
|
||||
|
||||
On first run the desktop app asks whether to use the BangSo Bot stack on this computer
|
||||
(`http://127.0.0.1:5173`) or connect to an existing server. Public servers must use HTTPS; HTTP is
|
||||
accepted only for loopback and private LAN addresses (not link-local). The app verifies BangSo Bot's
|
||||
health endpoint before saving, and later launches go straight to that instance.
|
||||
|
||||
Use **Change BangSo Bot Server…** in the application menu to reconnect. Closing that window without
|
||||
saving returns to the previous instance. For development automation, set `RAKAZO_WEB_URL` to point
|
||||
the shell somewhere else without changing the saved instance, or `RAKAZO_FORCE_SETUP=1` to run
|
||||
setup again.
|
||||
|
||||
Mobile build and release instructions live in [docs/mobile-release.md](./docs/mobile-release.md).
|
||||
|
||||
## Web UI language
|
||||
|
||||
The web (and Electron-hosted) UI supports English, Deutsch, 한국어, Türkçe, हिन्दी,
|
||||
Português (Brasil), and 繁體中文. Change it under
|
||||
**Settings → Language**. The marketing homepage (`apps/www`) is available in en/de/ko via
|
||||
footer language links (`/`, `/de/`, `/ko/`); other marketing pages stay English.
|
||||
|
||||
## Development
|
||||
|
||||
BangSo Bot is a TypeScript monorepo built with React, Electron, Expo, Hono, Postgres, Prisma, Graphile
|
||||
Worker, and Pi.
|
||||
|
||||
```text
|
||||
apps/ web, api, worker, desktop, mobile, and public website
|
||||
packages/ domain, contracts, persistence, adapters, UI, and test tooling
|
||||
infra/ local services and computer images
|
||||
docs/ architecture, operations, and release guides
|
||||
```
|
||||
|
||||
Common checks:
|
||||
|
||||
```bash
|
||||
pnpm lint
|
||||
pnpm check
|
||||
pnpm test
|
||||
pnpm test:integration
|
||||
pnpm test:e2e
|
||||
```
|
||||
|
||||
See [CONTRIBUTING.md](./CONTRIBUTING.md) for the development workflow and test matrix.
|
||||
|
||||
## Documentation
|
||||
|
||||
```bash
|
||||
pnpm test # unit, property, and in-process contract tests
|
||||
pnpm test:integration # Postgres journeys, Graphile jobs, LISTEN/NOTIFY
|
||||
pnpm test:e2e # Playwright against the emulated stack
|
||||
pnpm test:e2e -- --sandbox=e2b # the same deterministic suite against real E2B
|
||||
pnpm test:e2e -- --sandbox=daytona # the same suite against real Daytona
|
||||
pnpm test:e2e -- --sandbox=box # the same suite against real Box
|
||||
pnpm test:topology # local Docker + Graphile worker recovery (needs Docker)
|
||||
pnpm test:canary # live OpenRouter / E2B / Box canaries
|
||||
# explicit real vision-model + real E2B desktop acceptance test:
|
||||
COMPUTER_E2E_MODEL=<vision-capable-openrouter-model-id> pnpm test:computer
|
||||
```
|
||||
|
||||
- [Self-hosting](./docs/self-host.md)
|
||||
- [Computer runtime and isolation](./docs/computer-runtime.md)
|
||||
- [Mobile releases](./docs/mobile-release.md)
|
||||
- [Performance testing](./docs/performance.md)
|
||||
|
||||
## Contributing
|
||||
|
||||
The Playwright workflow can also be started manually with **Sandbox provider** set to `e2b`, `daytona`, or `box`.
|
||||
Those options require `E2B_API_KEY`, `DAYTONA_API_KEY`, or `BOX_API_KEY`, keep the deterministic scripted agent runtime, and destroy
|
||||
the provider machines after the run. The default and all automatic runs remain on `fake`.
|
||||
Contributions are welcome. Please read [CONTRIBUTING.md](./CONTRIBUTING.md) before opening a pull
|
||||
request. For security vulnerabilities, follow [SECURITY.md](./SECURITY.md) instead of filing a public
|
||||
issue.
|
||||
|
||||
BangSo Bot is licensed under the [Apache License 2.0](./LICENSE).
|
||||
|
||||
Questions and ideas are welcome in the [BangSo Bot Discord community](https://discord.gg/RWwKa2Sn7h).
|
||||
|
|
@ -0,0 +1,34 @@
|
|||
# Security
|
||||
|
||||
## Reporting vulnerabilities
|
||||
|
||||
Email **security@rakazo.com** only. Do not open public GitHub issues for security bugs.
|
||||
|
||||
Please include:
|
||||
|
||||
- Steps to reproduce
|
||||
- Impact (what an attacker could do)
|
||||
- Whether the issue is already public
|
||||
|
||||
We will acknowledge your report and work on a fix. Please do not file a public issue for unfixed vulnerabilities.
|
||||
|
||||
## Other contact
|
||||
|
||||
- General support: **support@rakazo.com**
|
||||
- Maintainer: **elie@rakazo.com**
|
||||
|
||||
## Scope
|
||||
|
||||
This policy covers the Rakazo self-hosted product in **this repository**.
|
||||
|
||||
Out of scope:
|
||||
|
||||
- Third-party AI models and their APIs
|
||||
- Composio, E2B, and other external services
|
||||
- Operator misconfiguration (exposed secrets, open databases, weak passwords)
|
||||
|
||||
## Supported versions
|
||||
|
||||
We support security fixes on the current `main` branch and the latest release (beta).
|
||||
|
||||
There is no bug bounty program at this time.
|
||||
|
|
@ -0,0 +1,137 @@
|
|||
# Set up Rakazo with a coding agent
|
||||
|
||||
Copy one of the prompts below into a coding agent.
|
||||
|
||||
## Published images (no checkout)
|
||||
|
||||
Prefer this when the user wants a running web UI with Docker only (no Node/pnpm clone).
|
||||
|
||||
```text
|
||||
Set up Rakazo from published GHCR images and leave the web UI running.
|
||||
|
||||
Work like a careful onboarding engineer: perform the setup yourself, explain only decisions or blockers, and verify the product through the UI.
|
||||
|
||||
Safety rules:
|
||||
|
||||
- Never overwrite an existing `.env`. If one exists, inspect only which keys are present (never print values), preserve it, and ask before changing existing values.
|
||||
- Never print, log, commit, or paste secrets into tracked files.
|
||||
- Do not kill unrelated processes or containers to free ports. Identify conflicts and ask before stopping anything.
|
||||
- Treat model or integration credentials as security-sensitive.
|
||||
|
||||
Before making changes, ask me these concise questions:
|
||||
|
||||
1. Which directory should contain the Rakazo folder (or use the current directory)?
|
||||
2. How should models be connected?
|
||||
- Add a deployment-wide `OPENROUTER_API_KEY` to `.env`.
|
||||
- Connect during Rakazo onboarding with a provider API key or with ChatGPT Plus/Pro, GitHub Copilot, or SuperGrok / X Premium.
|
||||
- Defer model setup and verify infrastructure only. Make clear that bots cannot answer until a model is connected.
|
||||
3. Do I want remote computers instead of local Docker? If yes, choose E2B (`E2B_API_KEY`), Daytona (`DAYTONA_API_KEY`), or Box (`BOX_API_KEY`) and set `SANDBOX_PROVIDER` accordingly. If no, keep the default `SANDBOX_PROVIDER=docker` (local computers via the in-stack supervisor).
|
||||
|
||||
Do not ask me to invent secrets; generate strong random values with openssl yourself.
|
||||
|
||||
Preflight:
|
||||
|
||||
- Verify Docker Engine and the Compose plugin are installed and the daemon is running.
|
||||
- Check whether `127.0.0.1` ports 3100 and 5173 are available.
|
||||
|
||||
Setup:
|
||||
|
||||
1. Create the directory if needed and enter it.
|
||||
2. Download and inspect this installer (do not clone the repository):
|
||||
https://raw.githubusercontent.com/elie222/rakazo/main/infra/compose/install-images.sh
|
||||
3. Run `bash install-images.sh --prepare-only`. It downloads the Compose and environment example
|
||||
files, then creates `.env` with all required random secrets when one does not already exist.
|
||||
4. Preserve existing values. Keep `SANDBOX_PROVIDER=docker` unless I chose a remote computer
|
||||
provider, and add only the provider or model keys I selected.
|
||||
5. Run `bash install-images.sh`. It preserves `.env`, pulls the images, and starts the stack.
|
||||
6. Wait until api, web, and supervisor are healthy. Default image tag is `edge` (amd64). Do not pin `latest` unless that tag exists in GHCR.
|
||||
|
||||
Verification:
|
||||
|
||||
- Request `http://127.0.0.1:3100/health`. Require `ok: true` and `sandbox: "docker"` (or the remote provider you configured). A missing `SANDBOX_SUPERVISOR_TOKEN` is a setup failure: Compose will not start the supervisor; restore the token and recreate the stack. Do not treat `sandbox: "none"` as success for this path.
|
||||
- Open `http://127.0.0.1:5173`, create a local test account with fake data, and complete first-run onboarding.
|
||||
- If a model is connected, send a harmless test message. Open the Agent computer pane and confirm the Docker computer reaches `running` and renders its desktop.
|
||||
|
||||
When finished, report the directory path, effective Docker/Compose versions, configured options without secrets, app URL, health result, and how to stop without deleting volumes (`docker compose … down` without `-v`).
|
||||
```
|
||||
|
||||
## Local source checkout (pnpm)
|
||||
|
||||
Use this for development, Docker sandboxes on the host, or Electron.
|
||||
|
||||
```text
|
||||
Set up Rakazo locally and leave it running in a usable state.
|
||||
|
||||
Repository: https://github.com/elie222/rakazo.git
|
||||
|
||||
Work like a careful onboarding engineer: perform the setup yourself, explain only decisions or blockers, and verify the product through the UI rather than stopping after dependency installation.
|
||||
|
||||
Safety rules:
|
||||
|
||||
- Never overwrite an existing `.env`. If one exists, inspect only which keys are present (never print values), preserve it, and ask before changing existing values.
|
||||
- Never print, log, commit, or paste secrets into tracked files. Confirm `.env` is ignored. Do not commit anything as part of setup.
|
||||
- Do not discard local changes if the repository already exists. Inspect `git status` first.
|
||||
- Do not kill unrelated processes or containers to free ports. Identify conflicts and ask before stopping anything; otherwise use a safe alternate configuration and document it.
|
||||
- Treat Docker/Desktop host access and model or integration credentials as security-sensitive.
|
||||
|
||||
Before making changes, ask me these concise questions:
|
||||
|
||||
1. Should you clone into the current directory, or what parent directory should contain `rakazo`? If you are already inside a Rakazo checkout, offer to use it without recloning.
|
||||
2. How should models be connected?
|
||||
- Add a deployment-wide `OPENROUTER_API_KEY` to `.env`.
|
||||
- Connect during Rakazo onboarding with a provider API key or with ChatGPT Plus/Pro, GitHub Copilot, or SuperGrok / X Premium.
|
||||
- Defer model setup and verify infrastructure only. Make clear that bots cannot answer until a model is connected.
|
||||
3. Do I want a managed app catalog? If yes, choose Composio (`COMPOSIO_API_KEY`) or Pipedream Connect (`PIPEDREAM_CLIENT_ID`, `PIPEDREAM_CLIENT_SECRET`, and `PIPEDREAM_PROJECT_ID`); otherwise leave them empty. Explain that this is optional and that users can still add Treg, HTTPS MCP, or OpenAPI sources in the app.
|
||||
4. Set up the web app only (recommended), or also launch the Electron desktop shell after the web stack works?
|
||||
|
||||
Do not ask me to invent `BETTER_AUTH_SECRET` or `ENCRYPTION_KEY`; generate strong random local values yourself. If I choose an API key, let me enter it through an available secure secret mechanism or directly into `.env`; never echo it back. OAuth or device-code sign-in must remain under my control.
|
||||
|
||||
Preflight:
|
||||
|
||||
- Verify Git, Node.js, pnpm, Docker, and Docker Compose.
|
||||
- Use Node.js 22 LTS (at least 22.12) and the repository-declared pnpm 9.15.0. Do not silently use pnpm 10 or 11: newer pnpm versions can reject this lockfile or rewrite it. Prefer Corepack; if Corepack is unavailable, use `npx --yes pnpm@9.15.0` for repo commands rather than globally installing a different version. Show the effective versions.
|
||||
- Verify the Docker daemon is running.
|
||||
- Check whether `127.0.0.1` ports 5433, 3100, 5173, and 7091 are available. Resolve conflicts without touching unrelated workloads.
|
||||
|
||||
Setup:
|
||||
|
||||
1. Clone the repository if needed and enter its root.
|
||||
2. Read `AGENTS.md`, `README.md`, `.env.example`, and the root `package.json` before acting. Follow repository instructions if they have changed since this prompt was written.
|
||||
3. If `.env` does not exist, copy `.env.example` to `.env`. Generate independent random values of at least 32 bytes for `BETTER_AUTH_SECRET` and `ENCRYPTION_KEY`. Keep local defaults for Postgres, origins, Pi, Docker, and Graphile unless the preflight found a conflict. Add only the model and managed-connector credentials I selected. Leave optional credentials blank.
|
||||
4. Confirm `.env` is ignored and that no secret-bearing file is staged.
|
||||
5. Start only local Postgres:
|
||||
|
||||
`docker compose --env-file .env -f infra/compose/docker-compose.yml up postgres -d`
|
||||
|
||||
6. With pnpm 9.15.0, run:
|
||||
|
||||
`pnpm install --frozen-lockfile`
|
||||
`pnpm db:generate`
|
||||
`pnpm db:migrate`
|
||||
`pnpm sandbox:build`
|
||||
|
||||
The first sandbox build may take several minutes because it installs a graphical Linux desktop and Chromium. If a command fails, diagnose the cause; do not bypass the lockfile or approve arbitrary dependency build scripts just to make progress.
|
||||
|
||||
7. Start `pnpm dev` in a persistent terminal. Wait until the API, worker, web app, and sandbox supervisor are ready. Keep the process running for me.
|
||||
|
||||
Verification:
|
||||
|
||||
- Request `http://127.0.0.1:3100/health`. Require `ok: true`, `runtime: "pi"`, `sandbox: "docker"`, `jobs: "graphile"`, and `realtime: "postgres"`. Expect `composio: true` only when its key was configured and `pipedream: true` only when all Pipedream settings were configured. `revision` is `null` unless `GIT_SHA` is set.
|
||||
- Open `http://127.0.0.1:5173` in a browser. If browser automation is available, use it for non-sensitive steps; otherwise give me the exact UI steps.
|
||||
- Create a local test account with clearly fake data, complete first-run onboarding, and create a test bot. Do not use personal data.
|
||||
- If a model is connected, send a harmless test message and confirm the bot replies. If model setup was deferred, explicitly report that the stack is healthy but a first message will fail until a provider is configured; do not call the setup fully usable without that caveat.
|
||||
- Open the Agent computer pane and confirm the Docker computer reaches `running` and renders its desktop.
|
||||
- Open Integrations. If neither managed catalog was configured, confirm the view still offers Treg, HTTPS MCP, and OpenAPI sources. If one was configured, verify its app catalog loads without exposing any key or client secret.
|
||||
- Run `pnpm test` and `pnpm check`. Report failures with the relevant output; do not claim success if either fails.
|
||||
- If I requested Electron, leave the web stack running and then launch `pnpm --filter @rakazo/desktop dev`. Verify the shell loads the same app. Let me make the Docker-versus-This-Mac choice because This Mac grants bots access under my OS account.
|
||||
|
||||
When finished, report:
|
||||
|
||||
- The absolute repository path and checked-out commit.
|
||||
- Effective Node, pnpm, Docker, and Docker Compose versions.
|
||||
- Which model-auth path and optional integrations are configured, without revealing secrets.
|
||||
- App URL, health result, UI/message/computer verification, and test/type-check results.
|
||||
- Every workaround or remaining limitation.
|
||||
- How to restart the stack.
|
||||
- How to stop it without deleting data. Do not use `pnpm compose:down` for a normal stop because that script includes `-v` and removes Compose volumes; use a non-destructive stop/down command without `-v` and explain it.
|
||||
```
|
||||
|
|
@ -0,0 +1,28 @@
|
|||
{
|
||||
"name": "@rakazo/api",
|
||||
"version": "0.1.0",
|
||||
"license": "Apache-2.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "node ../../scripts/dev-watch.mjs src/index.ts",
|
||||
"start": "tsx src/index.ts",
|
||||
"check": "tsc --noEmit -p tsconfig.json",
|
||||
"test": "vitest run --root ../.. apps/api/src"
|
||||
},
|
||||
"dependencies": {
|
||||
"@hono/node-server": "^1.19.1",
|
||||
"@orpc/server": "^1.15.0",
|
||||
"@rakazo/adapter-kit": "workspace:*",
|
||||
"@rakazo/adapters": "workspace:*",
|
||||
"@rakazo/auth": "workspace:*",
|
||||
"@rakazo/contracts": "workspace:*",
|
||||
"@rakazo/core": "workspace:*",
|
||||
"@rakazo/db": "workspace:*",
|
||||
"@rakazo/memory": "workspace:*",
|
||||
"hono": "^4.9.6"
|
||||
},
|
||||
"devDependencies": {
|
||||
"typescript": "^5.9.2",
|
||||
"vitest": "^4.1.10"
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,302 @@
|
|||
import { ORPCError } from "@orpc/server";
|
||||
import { BUILTIN_AGENT_SKILLS } from "@rakazo/adapters";
|
||||
import type { Actor, AgentSkill, AgentSkillSource } from "@rakazo/contracts";
|
||||
import { buildSkillMd, isSkillReadOnly, parseSkillMd, type SkillSource } from "@rakazo/core";
|
||||
import { IsolationError, type PrismaClient } from "@rakazo/db";
|
||||
|
||||
type AgentSkillRow = {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
content: string;
|
||||
source: string;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
};
|
||||
|
||||
function asSource(value: string): AgentSkillSource {
|
||||
if (value === "builtin" || value === "plugin" || value === "user") return value;
|
||||
return "user";
|
||||
}
|
||||
|
||||
export function mapAgentSkill(row: AgentSkillRow): AgentSkill {
|
||||
const source = asSource(row.source);
|
||||
return {
|
||||
id: row.id,
|
||||
name: row.name,
|
||||
description: row.description,
|
||||
content: row.content,
|
||||
source,
|
||||
readOnly: isSkillReadOnly(source as SkillSource),
|
||||
createdAt: row.createdAt.toISOString(),
|
||||
updatedAt: row.updatedAt.toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
function builtinCatalog(): AgentSkill[] {
|
||||
return BUILTIN_AGENT_SKILLS.map((skill) => ({
|
||||
id: `builtin:${skill.name}`,
|
||||
name: skill.name,
|
||||
description: skill.description,
|
||||
content: skill.content,
|
||||
source: "builtin" as const,
|
||||
readOnly: true,
|
||||
createdAt: new Date(0).toISOString(),
|
||||
updatedAt: new Date(0).toISOString(),
|
||||
}));
|
||||
}
|
||||
|
||||
export function resolveSkillContent(input: {
|
||||
content?: string;
|
||||
name?: string;
|
||||
description?: string;
|
||||
body?: string;
|
||||
prior?: { content: string };
|
||||
}): { name: string; description: string; content: string } {
|
||||
const ensureContentLimit = (content: string): string => {
|
||||
if (content.length > 100_000) {
|
||||
throw new ORPCError("BAD_REQUEST", {
|
||||
message: "Skill content must be at most 100000 characters.",
|
||||
});
|
||||
}
|
||||
return content;
|
||||
};
|
||||
|
||||
if (input.content?.trim()) {
|
||||
const parsed = parseSkillMd(input.content);
|
||||
if ("error" in parsed) {
|
||||
throw new ORPCError("BAD_REQUEST", { message: parsed.error });
|
||||
}
|
||||
return {
|
||||
name: parsed.name,
|
||||
description: parsed.description,
|
||||
content: ensureContentLimit(buildSkillMd(parsed)),
|
||||
};
|
||||
}
|
||||
|
||||
const priorParsed = input.prior ? parseSkillMd(input.prior.content) : null;
|
||||
if (priorParsed && "error" in priorParsed) {
|
||||
throw new ORPCError("BAD_REQUEST", { message: priorParsed.error });
|
||||
}
|
||||
|
||||
const name = (input.name ?? priorParsed?.name ?? "").trim();
|
||||
const description = (input.description ?? priorParsed?.description ?? "").trim();
|
||||
const body = input.body ?? priorParsed?.body ?? "";
|
||||
if (!name || !description) {
|
||||
throw new ORPCError("BAD_REQUEST", {
|
||||
message: "Provide content (SKILL.md) or name + description (+ optional body)",
|
||||
});
|
||||
}
|
||||
let content: string;
|
||||
try {
|
||||
content = buildSkillMd({
|
||||
name,
|
||||
description,
|
||||
body,
|
||||
frontmatter: priorParsed && !("error" in priorParsed) ? priorParsed.frontmatter : undefined,
|
||||
});
|
||||
} catch (error) {
|
||||
throw new ORPCError("BAD_REQUEST", {
|
||||
message: error instanceof Error ? error.message : "Invalid skill fields.",
|
||||
});
|
||||
}
|
||||
const validated = parseSkillMd(content);
|
||||
if ("error" in validated) {
|
||||
throw new ORPCError("BAD_REQUEST", { message: validated.error });
|
||||
}
|
||||
return {
|
||||
name: validated.name,
|
||||
description: validated.description,
|
||||
content: ensureContentLimit(buildSkillMd(validated)),
|
||||
};
|
||||
}
|
||||
|
||||
export function createAgentSkillsService(prisma: PrismaClient) {
|
||||
async function owned(actor: Actor, skillId: string) {
|
||||
const row = await prisma.agentSkill.findFirst({
|
||||
where: {
|
||||
id: skillId,
|
||||
spaceId: actor.spaceId,
|
||||
userId: actor.userId,
|
||||
},
|
||||
});
|
||||
if (!row) throw new IsolationError();
|
||||
return row;
|
||||
}
|
||||
|
||||
return {
|
||||
async list(actor: Actor): Promise<Omit<AgentSkill, "content">[]> {
|
||||
const rows = await prisma.agentSkill.findMany({
|
||||
where: { spaceId: actor.spaceId, userId: actor.userId },
|
||||
orderBy: [{ name: "asc" }, { id: "asc" }],
|
||||
});
|
||||
const catalog = [...builtinCatalog(), ...rows.map(mapAgentSkill)].map(
|
||||
({ content: _content, ...entry }) => entry,
|
||||
);
|
||||
return catalog;
|
||||
},
|
||||
|
||||
async listWithContent(actor: Actor): Promise<AgentSkill[]> {
|
||||
const rows = await prisma.agentSkill.findMany({
|
||||
where: { spaceId: actor.spaceId, userId: actor.userId },
|
||||
orderBy: [{ name: "asc" }, { id: "asc" }],
|
||||
});
|
||||
return [...builtinCatalog(), ...rows.map(mapAgentSkill)];
|
||||
},
|
||||
|
||||
async get(actor: Actor, input: { skillId?: string; name?: string }): Promise<AgentSkill> {
|
||||
if (input.skillId?.startsWith("builtin:")) {
|
||||
const builtin = builtinCatalog().find((skill) => skill.id === input.skillId);
|
||||
if (!builtin) throw new IsolationError();
|
||||
return builtin;
|
||||
}
|
||||
if (input.skillId) {
|
||||
return mapAgentSkill(await owned(actor, input.skillId));
|
||||
}
|
||||
const name = input.name?.trim() ?? "";
|
||||
const builtin = builtinCatalog().find(
|
||||
(skill) => skill.name.toLowerCase() === name.toLowerCase(),
|
||||
);
|
||||
if (builtin) return builtin;
|
||||
const row = await prisma.agentSkill.findFirst({
|
||||
where: {
|
||||
spaceId: actor.spaceId,
|
||||
userId: actor.userId,
|
||||
name: { equals: name, mode: "insensitive" },
|
||||
},
|
||||
});
|
||||
if (!row) throw new IsolationError();
|
||||
return mapAgentSkill(row);
|
||||
},
|
||||
|
||||
async create(
|
||||
actor: Actor,
|
||||
input: { content?: string; name?: string; description?: string; body?: string },
|
||||
): Promise<AgentSkill> {
|
||||
const resolved = resolveSkillContent(input);
|
||||
const clash = await prisma.agentSkill.findFirst({
|
||||
where: {
|
||||
spaceId: actor.spaceId,
|
||||
userId: actor.userId,
|
||||
name: { equals: resolved.name, mode: "insensitive" },
|
||||
},
|
||||
});
|
||||
if (
|
||||
clash ||
|
||||
builtinCatalog().some((s) => s.name.toLowerCase() === resolved.name.toLowerCase())
|
||||
) {
|
||||
throw new ORPCError("CONFLICT", { message: "A skill with that name already exists." });
|
||||
}
|
||||
try {
|
||||
const row = await prisma.agentSkill.create({
|
||||
data: {
|
||||
spaceId: actor.spaceId,
|
||||
userId: actor.userId,
|
||||
name: resolved.name,
|
||||
description: resolved.description,
|
||||
content: resolved.content,
|
||||
source: "user",
|
||||
},
|
||||
});
|
||||
return mapAgentSkill(row);
|
||||
} catch (error) {
|
||||
if (
|
||||
error instanceof Error &&
|
||||
"code" in error &&
|
||||
(error as { code?: string }).code === "P2002"
|
||||
) {
|
||||
throw new ORPCError("CONFLICT", { message: "A skill with that name already exists." });
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
async update(
|
||||
actor: Actor,
|
||||
input: {
|
||||
skillId: string;
|
||||
content?: string;
|
||||
name?: string;
|
||||
description?: string;
|
||||
body?: string;
|
||||
},
|
||||
): Promise<AgentSkill> {
|
||||
const existing = await owned(actor, input.skillId);
|
||||
if (isSkillReadOnly(asSource(existing.source) as SkillSource)) {
|
||||
throw new ORPCError("BAD_REQUEST", { message: "Builtin and plugin skills are read-only." });
|
||||
}
|
||||
const resolved = resolveSkillContent({ ...input, prior: existing });
|
||||
if (resolved.name.toLowerCase() !== existing.name.toLowerCase()) {
|
||||
const clash = await prisma.agentSkill.findFirst({
|
||||
where: {
|
||||
spaceId: actor.spaceId,
|
||||
userId: actor.userId,
|
||||
name: { equals: resolved.name, mode: "insensitive" },
|
||||
NOT: { id: existing.id },
|
||||
},
|
||||
});
|
||||
if (
|
||||
clash ||
|
||||
builtinCatalog().some((s) => s.name.toLowerCase() === resolved.name.toLowerCase())
|
||||
) {
|
||||
throw new ORPCError("CONFLICT", { message: "A skill with that name already exists." });
|
||||
}
|
||||
}
|
||||
// Mutate only owner-scoped user rows (never builtin/plugin), even if source was tampered.
|
||||
try {
|
||||
const updated = await prisma.agentSkill.updateMany({
|
||||
where: {
|
||||
id: existing.id,
|
||||
spaceId: actor.spaceId,
|
||||
userId: actor.userId,
|
||||
source: "user",
|
||||
},
|
||||
data: {
|
||||
name: resolved.name,
|
||||
description: resolved.description,
|
||||
content: resolved.content,
|
||||
},
|
||||
});
|
||||
if (updated.count !== 1) throw new IsolationError();
|
||||
} catch (error) {
|
||||
if (error instanceof IsolationError) throw error;
|
||||
if (
|
||||
error instanceof Error &&
|
||||
"code" in error &&
|
||||
(error as { code?: string }).code === "P2002"
|
||||
) {
|
||||
throw new ORPCError("CONFLICT", { message: "A skill with that name already exists." });
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
const row = await prisma.agentSkill.findFirst({
|
||||
where: {
|
||||
id: existing.id,
|
||||
spaceId: actor.spaceId,
|
||||
userId: actor.userId,
|
||||
},
|
||||
});
|
||||
if (!row) throw new IsolationError();
|
||||
return mapAgentSkill(row);
|
||||
},
|
||||
|
||||
async remove(actor: Actor, skillId: string): Promise<{ ok: true }> {
|
||||
const existing = await owned(actor, skillId);
|
||||
if (isSkillReadOnly(asSource(existing.source) as SkillSource)) {
|
||||
throw new ORPCError("BAD_REQUEST", { message: "Builtin and plugin skills are read-only." });
|
||||
}
|
||||
const deleted = await prisma.agentSkill.deleteMany({
|
||||
where: {
|
||||
id: existing.id,
|
||||
spaceId: actor.spaceId,
|
||||
userId: actor.userId,
|
||||
source: "user",
|
||||
},
|
||||
});
|
||||
if (deleted.count !== 1) throw new IsolationError();
|
||||
return { ok: true };
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export type AgentSkillsService = ReturnType<typeof createAgentSkillsService>;
|
||||
|
|
@ -0,0 +1,522 @@
|
|||
import { randomUUID } from "node:crypto";
|
||||
import { rm } from "node:fs/promises";
|
||||
import { ORPCError, onError } from "@orpc/server";
|
||||
import { RPCHandler } from "@orpc/server/fetch";
|
||||
import type {
|
||||
JobPublisher,
|
||||
ManagedConnectorProvider,
|
||||
MessagingSurface,
|
||||
RealtimeFanout,
|
||||
SandboxProvider,
|
||||
TransactionalEmailProvider,
|
||||
} from "@rakazo/adapter-kit";
|
||||
import {
|
||||
applyMessagingOutboundStatus,
|
||||
ChatSdkMessagingSurface,
|
||||
type ComposioProvider,
|
||||
type ConnectorRegistry,
|
||||
createBackgroundJobHandlers,
|
||||
createConnectorStack,
|
||||
createJobReconciler,
|
||||
createMessagingContextLoader,
|
||||
createRunExecutor,
|
||||
createRunSandbox,
|
||||
createRunSecretWriter,
|
||||
createWebProvider,
|
||||
type DestinationEmulator,
|
||||
destroyBot,
|
||||
EmailEmulator,
|
||||
EncryptedSecretStore,
|
||||
ExpoPushProvider,
|
||||
GraphileJobPublisher,
|
||||
InMemoryJobQueue,
|
||||
InMemoryRealtimeFanout,
|
||||
InstalledConnectorProvider,
|
||||
isComposioEnabled,
|
||||
isMessagingSurfaceEnabled,
|
||||
isPipedreamEnabled,
|
||||
LocalAgentHomeStore,
|
||||
LocalArtifactStore,
|
||||
McpConnector,
|
||||
McpOAuthBroker,
|
||||
messagingPlatformsFromEnv,
|
||||
PiAgentRuntime,
|
||||
PiOAuthLogins,
|
||||
PipedreamConnector,
|
||||
PostgresRealtimeFanout,
|
||||
pipedreamConfigFromEnv,
|
||||
pushTokenPath,
|
||||
type RemoteConnectorDependencies,
|
||||
ScriptedAgentRuntime,
|
||||
SmtpEmailProvider,
|
||||
SpaceMemoryProviderResolver,
|
||||
} from "@rakazo/adapters";
|
||||
import { blockedAuthPaths, createAuth } from "@rakazo/auth";
|
||||
import { signupPolicyFromEnv } from "@rakazo/core";
|
||||
import {
|
||||
createDb,
|
||||
createThreadEvents,
|
||||
type PrismaClient,
|
||||
provisionMessagingIdentity,
|
||||
requireMembership,
|
||||
} from "@rakazo/db";
|
||||
import { MarkdownMemoryStore } from "@rakazo/memory";
|
||||
import { type Context, Hono } from "hono";
|
||||
import { cors } from "hono/cors";
|
||||
import { mountBotAvatarHttpRoutes } from "./bot-avatar.js";
|
||||
import { type AppEnv, loadEnv } from "./env.js";
|
||||
import { createMessagingInboundHandler } from "./messaging-inbound.js";
|
||||
import { mountMessagingWebhookRoutes } from "./messaging-webhook.js";
|
||||
import { createRouter } from "./router.js";
|
||||
import { mountVoiceHttpRoutes } from "./voice.js";
|
||||
import { mountWebhookHttpRoutes } from "./webhook.js";
|
||||
|
||||
export interface AppHandles {
|
||||
app: Hono;
|
||||
prisma: PrismaClient;
|
||||
jobs: JobPublisher;
|
||||
sandbox: SandboxProvider;
|
||||
connector: DestinationEmulator;
|
||||
composio?: ComposioProvider;
|
||||
connectors: ConnectorRegistry;
|
||||
messaging?: MessagingSurface;
|
||||
email?: TransactionalEmailProvider;
|
||||
executor: ReturnType<typeof createRunExecutor>;
|
||||
stop: () => Promise<void>;
|
||||
}
|
||||
|
||||
export async function createApp(
|
||||
overrides: Partial<AppEnv> & {
|
||||
prisma?: PrismaClient;
|
||||
realtime?: RealtimeFanout;
|
||||
composio?: ComposioProvider;
|
||||
pipedream?: ManagedConnectorProvider;
|
||||
messaging?: MessagingSurface;
|
||||
email?: TransactionalEmailProvider;
|
||||
remoteConnectors?: RemoteConnectorDependencies;
|
||||
} = {},
|
||||
): Promise<AppHandles> {
|
||||
const {
|
||||
prisma: prismaOverride,
|
||||
realtime: realtimeOverride,
|
||||
composio: composioOverride,
|
||||
pipedream: pipedreamOverride,
|
||||
messaging: messagingOverride,
|
||||
email: emailOverride,
|
||||
remoteConnectors,
|
||||
...envOverrides
|
||||
} = overrides;
|
||||
const env = { ...loadEnv(process.env), ...envOverrides };
|
||||
const created = prismaOverride
|
||||
? { prisma: prismaOverride, pool: undefined }
|
||||
: createDb(env.databaseUrl);
|
||||
const { prisma } = created;
|
||||
created.pool?.on("error", () => undefined);
|
||||
const realtime =
|
||||
realtimeOverride ??
|
||||
(created.pool
|
||||
? new PostgresRealtimeFanout({
|
||||
connectionString: env.realtimeDatabaseUrl,
|
||||
publisher: created.pool,
|
||||
})
|
||||
: new InMemoryRealtimeFanout());
|
||||
const secrets = new EncryptedSecretStore(env.encryptionKey);
|
||||
const events = createThreadEvents(prisma, realtime, {
|
||||
runSecretWriter: createRunSecretWriter(secrets),
|
||||
});
|
||||
const environmentSignupPolicy = signupPolicyFromEnv(env);
|
||||
const deploymentSettings = await prisma.deploymentSettings.upsert({
|
||||
where: { id: "default" },
|
||||
create: {
|
||||
id: "default",
|
||||
signupsEnabled: environmentSignupPolicy.enabled,
|
||||
signupAllowlist: environmentSignupPolicy.allowlist.join(","),
|
||||
signupPolicyInitialized: true,
|
||||
},
|
||||
update: {},
|
||||
});
|
||||
if (!deploymentSettings.signupPolicyInitialized) {
|
||||
// Older versions created this row with schema defaults even though auth
|
||||
// still enforced the environment policy. Copy that effective policy once
|
||||
// so upgrades preserve behavior before Settings becomes authoritative.
|
||||
await prisma.deploymentSettings.updateMany({
|
||||
where: { id: "default", signupPolicyInitialized: false },
|
||||
data: {
|
||||
signupsEnabled: environmentSignupPolicy.enabled,
|
||||
signupAllowlist: environmentSignupPolicy.allowlist.join(","),
|
||||
signupPolicyInitialized: true,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const jobKind = env.wakeupDriver;
|
||||
const inMemoryJobs = jobKind === "memory" ? new InMemoryJobQueue() : undefined;
|
||||
const jobs = inMemoryJobs ?? new GraphileJobPublisher(env.databaseUrl);
|
||||
const sandbox: SandboxProvider = createRunSandbox(env.sandboxProvider, {
|
||||
supervisorUrl: env.sandboxSupervisorUrl,
|
||||
supervisorToken: env.sandboxSupervisorToken,
|
||||
e2bApiKey: env.e2bApiKey,
|
||||
daytonaApiKey: env.daytonaApiKey,
|
||||
daytonaApiUrl: env.daytonaApiUrl,
|
||||
daytonaTarget: env.daytonaTarget,
|
||||
boxApiKey: env.boxApiKey,
|
||||
boxApiUrl: env.boxApiUrl,
|
||||
dataDir: env.dataDir,
|
||||
prisma,
|
||||
});
|
||||
const mcpOAuth = new McpOAuthBroker(prisma, secrets, remoteConnectors);
|
||||
const memoryProviders = new SpaceMemoryProviderResolver(prisma, secrets);
|
||||
const oauthLogins = new PiOAuthLogins();
|
||||
const home = new LocalAgentHomeStore(env.dataDir);
|
||||
const artifacts = new LocalArtifactStore(env.dataDir);
|
||||
const memory = new MarkdownMemoryStore(prisma);
|
||||
const mcp = new McpConnector(
|
||||
prisma,
|
||||
secrets,
|
||||
{
|
||||
stdioEnabled: env.mcpStdioEnabled,
|
||||
allowedCommands: env.mcpStdioAllowedCommands,
|
||||
network: remoteConnectors,
|
||||
},
|
||||
mcpOAuth,
|
||||
);
|
||||
const pipedreamConfig = pipedreamConfigFromEnv(env);
|
||||
const pipedream =
|
||||
pipedreamOverride ??
|
||||
(isPipedreamEnabled(pipedreamConfig) ? new PipedreamConnector(pipedreamConfig) : undefined);
|
||||
const messagingPlatforms = messagingPlatformsFromEnv(env);
|
||||
const messaging =
|
||||
messagingOverride ??
|
||||
(isMessagingSurfaceEnabled(messagingPlatforms, {
|
||||
deploymentModelKey: env.deploymentModelKey,
|
||||
openSignup: env.messagingOpenSignup,
|
||||
})
|
||||
? new ChatSdkMessagingSurface(messagingPlatforms)
|
||||
: undefined);
|
||||
const localEmailEmulator =
|
||||
!emailOverride && !env.smtpUrl && env.emailEmulator
|
||||
? new EmailEmulator((message) => {
|
||||
console.info(`[email-emulator] captured ${message.subject} to ${message.to}`);
|
||||
})
|
||||
: undefined;
|
||||
if (localEmailEmulator && !isLoopbackHost(env.apiHost)) {
|
||||
throw new Error("EMAIL_EMULATOR requires API_HOST to be a loopback host");
|
||||
}
|
||||
const email: TransactionalEmailProvider | undefined =
|
||||
emailOverride ??
|
||||
(env.smtpUrl
|
||||
? new SmtpEmailProvider({ url: env.smtpUrl, from: env.emailFrom ?? "" })
|
||||
: localEmailEmulator);
|
||||
const installed = new InstalledConnectorProvider(prisma, secrets, remoteConnectors);
|
||||
const stack = createConnectorStack(isComposioEnabled(env.composioApiKey), composioOverride, [
|
||||
installed,
|
||||
...(pipedream ? [pipedream] : []),
|
||||
mcp,
|
||||
]);
|
||||
const connector = stack.destination;
|
||||
await connector.start();
|
||||
void stack.composio?.warmDirectory().catch(() => undefined);
|
||||
void pipedream?.warmDirectory?.().catch(() => undefined);
|
||||
const runtime =
|
||||
env.agentRuntime === "scripted" ? new ScriptedAgentRuntime() : new PiAgentRuntime();
|
||||
const notifications = new ExpoPushProvider(env.dataDir);
|
||||
const auth = createAuth(prisma, {
|
||||
secret: env.authSecret,
|
||||
baseURL: env.authUrl,
|
||||
webOrigin: env.webOrigin,
|
||||
signupsEnabled: env.signupsEnabled,
|
||||
signupAllowlist: env.signupAllowlist,
|
||||
email,
|
||||
onEmailError: (error) => console.error("transactional email delivery failed", error),
|
||||
extraOrigins: [
|
||||
"rakazo://",
|
||||
"exp://",
|
||||
"exp://*",
|
||||
"http://localhost:8081",
|
||||
"http://127.0.0.1:8081",
|
||||
"http://localhost:19006",
|
||||
"http://127.0.0.1:19006",
|
||||
],
|
||||
beforeDeleteUser: async (userId) => {
|
||||
const bots = await prisma.bot.findMany({
|
||||
where: { userId },
|
||||
select: { id: true, spaceId: true, name: true, archivedAt: true },
|
||||
});
|
||||
await Promise.all(
|
||||
bots.map((bot) =>
|
||||
destroyBot(
|
||||
{ prisma, sandbox, home, jobs, artifacts, dataDir: env.dataDir },
|
||||
bot,
|
||||
{
|
||||
operationId: `account-delete:${userId}`,
|
||||
traceId: `account-delete:${userId}`,
|
||||
spaceId: bot.spaceId,
|
||||
userId,
|
||||
botId: bot.id,
|
||||
signal: new AbortController().signal,
|
||||
},
|
||||
{ deleteMemories: true },
|
||||
),
|
||||
),
|
||||
);
|
||||
await rm(pushTokenPath(env.dataDir, userId), { force: true }).catch(() => undefined);
|
||||
},
|
||||
});
|
||||
const executor = createRunExecutor({
|
||||
prisma,
|
||||
runtime,
|
||||
sandbox,
|
||||
memory,
|
||||
memoryProviders,
|
||||
home,
|
||||
artifacts,
|
||||
connector: stack.connector,
|
||||
connectors: stack.connector,
|
||||
listConnectedPluginSlugs: stack.composio?.listConnectedSlugs.bind(stack.composio),
|
||||
secrets: [env.deploymentModelKey ?? "", env.composioApiKey ?? ""].filter(Boolean),
|
||||
secretStore: secrets,
|
||||
deploymentModelKey: env.deploymentModelKey,
|
||||
dataDir: env.dataDir,
|
||||
notifications,
|
||||
jobs,
|
||||
events,
|
||||
messaging: messaging ? createMessagingContextLoader(prisma) : undefined,
|
||||
web: createWebProvider(),
|
||||
});
|
||||
|
||||
const jobHandlers = createBackgroundJobHandlers({
|
||||
executor,
|
||||
prisma,
|
||||
sandbox,
|
||||
home,
|
||||
jobs,
|
||||
events,
|
||||
workerId: "api",
|
||||
runtime,
|
||||
secretStore: secrets,
|
||||
memoryProviders,
|
||||
deploymentModelKey: env.deploymentModelKey,
|
||||
messaging,
|
||||
});
|
||||
if (inMemoryJobs) {
|
||||
await inMemoryJobs.start(jobHandlers);
|
||||
}
|
||||
const reconciler = inMemoryJobs ? createJobReconciler({ prisma, jobs }) : undefined;
|
||||
reconciler?.start();
|
||||
|
||||
const router = createRouter({
|
||||
prisma,
|
||||
events,
|
||||
auth,
|
||||
jobs,
|
||||
sandbox,
|
||||
memory,
|
||||
memoryProviders,
|
||||
home,
|
||||
secrets,
|
||||
oauthLogins,
|
||||
mcpOAuth,
|
||||
composio: stack.composio,
|
||||
connectors: stack.connector,
|
||||
remoteConnectors,
|
||||
artifacts,
|
||||
dataDir: env.dataDir,
|
||||
messaging: {
|
||||
enabled: Boolean(messaging),
|
||||
providers: messaging?.platforms().map((platform) => platform.provider) ?? [],
|
||||
openSignup: env.messagingOpenSignup,
|
||||
},
|
||||
env: {
|
||||
defaultProvider: env.defaultProvider,
|
||||
defaultModel: env.defaultModel,
|
||||
deploymentModelKey: env.deploymentModelKey,
|
||||
webOrigin: env.webOrigin,
|
||||
screenProxySecret: env.screenProxySecret,
|
||||
sandboxProvider: env.sandboxProvider,
|
||||
gitSha: env.gitSha,
|
||||
updaterUrl: env.updaterUrl,
|
||||
updaterToken: env.updaterToken,
|
||||
imageTag: env.imageTag,
|
||||
},
|
||||
});
|
||||
const rpc = new RPCHandler(router, {
|
||||
clientInterceptors: [onError((error, { path }) => logUnexpectedRpcError(error, path))],
|
||||
});
|
||||
const app = new Hono();
|
||||
app.use(
|
||||
"*",
|
||||
cors({
|
||||
origin: (origin) => {
|
||||
if (!origin) return env.webOrigin;
|
||||
return isTrustedOrigin(origin, env) ? origin : "";
|
||||
},
|
||||
credentials: true,
|
||||
}),
|
||||
);
|
||||
app.get("/api/auth/capabilities", (c) =>
|
||||
c.json({
|
||||
passwordReset: Boolean(email),
|
||||
resetUrl: email ? new URL("/reset-password", env.webOrigin).href : null,
|
||||
}),
|
||||
);
|
||||
if (localEmailEmulator && env.nodeEnv === "development") {
|
||||
app.get(
|
||||
"/api/dev/emails",
|
||||
() =>
|
||||
new Response(JSON.stringify(localEmailEmulator.sent), {
|
||||
headers: { "cache-control": "no-store", "content-type": "application/json" },
|
||||
}),
|
||||
);
|
||||
}
|
||||
app.on(["GET", "POST"], "/api/auth/*", async (c) => {
|
||||
const path = new URL(c.req.url).pathname.replace("/api/auth", "");
|
||||
if (blockedAuthPaths.some((blocked) => path.startsWith(blocked))) {
|
||||
return c.json({ error: "Not available in version 1" }, 404);
|
||||
}
|
||||
return auth.handler(c.req.raw);
|
||||
});
|
||||
app.use("/rpc/*", async (c, next) => {
|
||||
const session = await auth.api.getSession({ headers: sessionHeaders(c.req.raw) });
|
||||
const requestedSpaceId = c.req.header("x-rakazo-space-id");
|
||||
const actor = session?.user
|
||||
? await requireMembership(prisma, session.user.id, requestedSpaceId).catch(() => null)
|
||||
: null;
|
||||
const { matched, response } = await rpc.handle(c.req.raw, {
|
||||
prefix: "/rpc",
|
||||
context: { actor, signal: c.req.raw.signal },
|
||||
});
|
||||
if (matched) return c.newResponse(response.body, response);
|
||||
await next();
|
||||
});
|
||||
const authenticate = async (c: Context) => {
|
||||
const session = await auth.api.getSession({ headers: sessionHeaders(c.req.raw) });
|
||||
if (!session?.user) return null;
|
||||
return requireMembership(prisma, session.user.id, c.req.header("x-rakazo-space-id")).catch(
|
||||
() => null,
|
||||
);
|
||||
};
|
||||
mountVoiceHttpRoutes(app, { prisma, secrets }, authenticate);
|
||||
mountBotAvatarHttpRoutes(app, { prisma, artifacts }, authenticate);
|
||||
mountWebhookHttpRoutes(app, { prisma, secrets, events, jobs });
|
||||
// Messaging webhooks only exist when the surface is enabled.
|
||||
if (messaging) {
|
||||
const inbound = createMessagingInboundHandler({
|
||||
prisma,
|
||||
events,
|
||||
jobs,
|
||||
provision: (request, policyEnv) => provisionMessagingIdentity(prisma, request, policyEnv),
|
||||
openSignup: env.messagingOpenSignup,
|
||||
signupPolicy: {
|
||||
signupsEnabled: env.signupsEnabled,
|
||||
signupAllowlist: env.signupAllowlist,
|
||||
},
|
||||
typing: (threadId) => {
|
||||
// Keep conversation addresses out of trace ids — those reach logs
|
||||
// and telemetry, a different trust boundary than the database.
|
||||
const operationId = `messaging.typing:${randomUUID()}`;
|
||||
return messaging.sendTyping(threadId, {
|
||||
operationId,
|
||||
traceId: operationId,
|
||||
spaceId: "",
|
||||
userId: "",
|
||||
// Cosmetic side call: the wait is bounded so a stalled vendor
|
||||
// response never holds our callback chain (the Chat SDK adapter
|
||||
// API cannot cancel the underlying request itself).
|
||||
signal: AbortSignal.timeout(2000),
|
||||
});
|
||||
},
|
||||
});
|
||||
messaging.onInbound(async (event) => {
|
||||
if (event.type === "message") await inbound(event);
|
||||
else await applyMessagingOutboundStatus(prisma, event);
|
||||
});
|
||||
mountMessagingWebhookRoutes(app, { messaging });
|
||||
}
|
||||
|
||||
app.get("/health", (c) =>
|
||||
c.json({
|
||||
ok: true,
|
||||
runtime: env.agentRuntime,
|
||||
sandbox: env.sandboxProvider,
|
||||
composio: Boolean(stack.composio),
|
||||
pipedream: Boolean(pipedream),
|
||||
messaging: Boolean(messaging),
|
||||
email: email?.describe().id ?? null,
|
||||
jobs: jobKind,
|
||||
realtime: realtime.describe().id,
|
||||
revision: env.gitSha ?? null,
|
||||
}),
|
||||
);
|
||||
|
||||
return {
|
||||
app,
|
||||
prisma,
|
||||
jobs,
|
||||
sandbox,
|
||||
connector,
|
||||
composio: stack.composio,
|
||||
connectors: stack.connector,
|
||||
messaging,
|
||||
email,
|
||||
executor,
|
||||
stop: async () => {
|
||||
oauthLogins.abortAll();
|
||||
await email?.drain?.();
|
||||
await reconciler?.stop();
|
||||
await jobs.close();
|
||||
await realtime.close();
|
||||
await connector.stop();
|
||||
await mcp.close();
|
||||
await prisma.$disconnect().catch(() => undefined);
|
||||
await created.pool?.end().catch(() => undefined);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function isTrustedOrigin(origin: string, env: AppEnv) {
|
||||
if (!origin) return true;
|
||||
if (origin === env.webOrigin || origin === env.apiUrl || origin === env.authUrl) return true;
|
||||
if (origin.startsWith("rakazo://") || origin.startsWith("exp://")) return true;
|
||||
try {
|
||||
const host = new URL(origin).hostname;
|
||||
return isLoopbackHost(host);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function isLoopbackHost(host: string): boolean {
|
||||
return host === "localhost" || host === "127.0.0.1" || host === "::1" || host === "[::1]";
|
||||
}
|
||||
|
||||
function sessionHeaders(request: Request) {
|
||||
const headers = new Headers(request.headers);
|
||||
const authz = headers.get("authorization");
|
||||
if (authz?.toLowerCase().startsWith("bearer ") && !headers.get("cookie")) {
|
||||
headers.set("cookie", `better-auth.session_token=${authz.slice(7).trim()}`);
|
||||
}
|
||||
return headers;
|
||||
}
|
||||
|
||||
/**
|
||||
* An ORPCError is a decision the router made (BAD_REQUEST, UNAUTHORIZED, ...) and reaches the
|
||||
* caller intact. Everything else is flattened into an opaque "Internal server error", so
|
||||
* unless it is logged here the only record of what actually broke is gone.
|
||||
*
|
||||
* The cause chain matters as much as the message: undici and most SDKs report a bare
|
||||
* "fetch failed" and keep the host and errno one level down.
|
||||
*/
|
||||
export function logUnexpectedRpcError(error: unknown, path: readonly string[]): void {
|
||||
if (error instanceof ORPCError) return;
|
||||
const where = `rpc ${path.join("/")} failed`;
|
||||
if (!(error instanceof Error)) {
|
||||
console.error(where, String(error));
|
||||
return;
|
||||
}
|
||||
const chain: string[] = [];
|
||||
for (let current: unknown = error; current instanceof Error && chain.length < 4; ) {
|
||||
chain.push(`${current.name}: ${current.message}`);
|
||||
current = current.cause;
|
||||
}
|
||||
console.error(where, chain.join(" <- "), error.stack ?? "");
|
||||
}
|
||||
|
|
@ -0,0 +1,337 @@
|
|||
import { createHash } from "node:crypto";
|
||||
import type { ArtifactStore } from "@rakazo/adapter-kit";
|
||||
import type { Actor } from "@rakazo/contracts";
|
||||
import { ATTACHMENT_MAX_COUNT } from "@rakazo/contracts";
|
||||
import {
|
||||
AttachmentValidationError,
|
||||
decodeAttachmentBase64,
|
||||
messageBlockForArtifact,
|
||||
promptTextForAttachments,
|
||||
validateAttachmentMimeType,
|
||||
} from "@rakazo/core";
|
||||
import { IsolationError, type PrismaClient } from "@rakazo/db";
|
||||
|
||||
function adapterContext(actor: Actor, botId: string, operationId: string) {
|
||||
return {
|
||||
operationId,
|
||||
traceId: operationId,
|
||||
spaceId: actor.spaceId,
|
||||
userId: actor.userId,
|
||||
botId,
|
||||
signal: new AbortController().signal,
|
||||
};
|
||||
}
|
||||
|
||||
export async function createOwnedArtifact(
|
||||
deps: {
|
||||
prisma: PrismaClient;
|
||||
artifacts: ArtifactStore;
|
||||
},
|
||||
actor: Actor,
|
||||
input: {
|
||||
botId: string;
|
||||
groupId?: string;
|
||||
name: string;
|
||||
mimeType: string;
|
||||
contentBase64: string;
|
||||
},
|
||||
) {
|
||||
validateAttachmentMimeType(input.mimeType);
|
||||
const bytes = decodeAttachmentBase64(input.contentBase64);
|
||||
const context = adapterContext(actor, input.botId, `artifact-create:${input.botId}`);
|
||||
const stored = await deps.artifacts.put(
|
||||
{ name: input.name, mimeType: input.mimeType, bytes },
|
||||
context,
|
||||
);
|
||||
const hash = createHash("sha256").update(bytes).digest("hex");
|
||||
const row = await deps.prisma.artifact
|
||||
.create({
|
||||
data: {
|
||||
spaceId: actor.spaceId,
|
||||
userId: actor.userId,
|
||||
botId: input.botId,
|
||||
groupId: input.groupId,
|
||||
name: input.name,
|
||||
mimeType: input.mimeType,
|
||||
size: bytes.byteLength,
|
||||
hash,
|
||||
storageKey: stored.id,
|
||||
},
|
||||
})
|
||||
.catch(async (error) => {
|
||||
await deps.artifacts.remove(stored.id, context).catch(() => undefined);
|
||||
throw error;
|
||||
});
|
||||
return {
|
||||
id: row.id,
|
||||
botId: row.botId,
|
||||
groupId: row.groupId,
|
||||
runId: row.runId,
|
||||
name: row.name,
|
||||
mimeType: row.mimeType,
|
||||
size: row.size,
|
||||
createdAt: row.createdAt.toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
export async function copyBotAvatarImage(
|
||||
deps: {
|
||||
prisma: PrismaClient;
|
||||
artifacts: ArtifactStore;
|
||||
},
|
||||
actor: Actor,
|
||||
input: { sourceBotId: string; sourceArtifactId: string; targetBotId: string },
|
||||
) {
|
||||
const source = await getOwnedArtifact(deps, actor, {
|
||||
botId: input.sourceBotId,
|
||||
artifactId: input.sourceArtifactId,
|
||||
});
|
||||
const created = await createOwnedArtifact(deps, actor, {
|
||||
botId: input.targetBotId,
|
||||
name: source.name,
|
||||
mimeType: source.mimeType,
|
||||
contentBase64: source.contentBase64,
|
||||
});
|
||||
await deps.prisma.bot.update({
|
||||
where: { id: input.targetBotId },
|
||||
data: { avatarImageArtifactId: created.id },
|
||||
});
|
||||
return created;
|
||||
}
|
||||
|
||||
export async function readBotAvatarImage(
|
||||
deps: {
|
||||
prisma: PrismaClient;
|
||||
artifacts: ArtifactStore;
|
||||
},
|
||||
userId: string,
|
||||
botId: string,
|
||||
) {
|
||||
const bot = await deps.prisma.bot.findFirst({
|
||||
where: { id: botId, userId, avatarImageArtifactId: { not: null } },
|
||||
select: { avatarImageArtifactId: true, spaceId: true },
|
||||
});
|
||||
if (!bot?.avatarImageArtifactId) return null;
|
||||
const row = await deps.prisma.artifact.findFirst({
|
||||
where: { id: bot.avatarImageArtifactId, userId },
|
||||
});
|
||||
if (!row) return null;
|
||||
const bytes = await deps.artifacts.get(
|
||||
row.storageKey,
|
||||
adapterContext(
|
||||
{ userId, spaceId: bot.spaceId, email: "", isDeploymentOwner: false },
|
||||
botId,
|
||||
`bot-avatar:${botId}`,
|
||||
),
|
||||
);
|
||||
return { bytes, mimeType: row.mimeType, hash: row.hash };
|
||||
}
|
||||
|
||||
export async function removeOwnedArtifact(
|
||||
deps: {
|
||||
prisma: PrismaClient;
|
||||
artifacts: ArtifactStore;
|
||||
},
|
||||
actor: Actor,
|
||||
input: { botId: string; artifactId: string },
|
||||
) {
|
||||
const row = await deps.prisma.artifact.findFirst({
|
||||
where: {
|
||||
id: input.artifactId,
|
||||
botId: input.botId,
|
||||
spaceId: actor.spaceId,
|
||||
userId: actor.userId,
|
||||
},
|
||||
});
|
||||
if (!row) return;
|
||||
await deps.prisma.artifact.delete({ where: { id: row.id } });
|
||||
await deps.artifacts
|
||||
.remove(row.storageKey, adapterContext(actor, input.botId, `artifact-remove:${row.id}`))
|
||||
.catch(() => undefined);
|
||||
}
|
||||
|
||||
export async function getOwnedArtifact(
|
||||
deps: {
|
||||
prisma: PrismaClient;
|
||||
artifacts: ArtifactStore;
|
||||
},
|
||||
actor: Actor,
|
||||
input: { botId: string; artifactId: string },
|
||||
) {
|
||||
const row = await deps.prisma.artifact.findFirst({
|
||||
where: {
|
||||
id: input.artifactId,
|
||||
botId: input.botId,
|
||||
groupId: null,
|
||||
spaceId: actor.spaceId,
|
||||
userId: actor.userId,
|
||||
},
|
||||
});
|
||||
if (!row) throw new IsolationError();
|
||||
return readArtifact(deps.artifacts, actor, row, input.botId);
|
||||
}
|
||||
|
||||
export async function getSpaceArtifact(
|
||||
deps: {
|
||||
prisma: PrismaClient;
|
||||
artifacts: ArtifactStore;
|
||||
},
|
||||
actor: Actor,
|
||||
input: { artifactId: string; groupId: string; contextBotId: string },
|
||||
) {
|
||||
const row = await deps.prisma.artifact.findFirst({
|
||||
where: {
|
||||
id: input.artifactId,
|
||||
groupId: input.groupId,
|
||||
spaceId: actor.spaceId,
|
||||
userId: actor.userId,
|
||||
},
|
||||
});
|
||||
if (!row) throw new IsolationError();
|
||||
return readArtifact(deps.artifacts, actor, row, input.contextBotId);
|
||||
}
|
||||
|
||||
async function readArtifact(
|
||||
artifacts: ArtifactStore,
|
||||
actor: Actor,
|
||||
row: {
|
||||
id: string;
|
||||
botId: string | null;
|
||||
groupId: string | null;
|
||||
runId: string | null;
|
||||
storageKey: string;
|
||||
name: string;
|
||||
mimeType: string;
|
||||
size: number;
|
||||
createdAt: Date;
|
||||
},
|
||||
contextBotId: string,
|
||||
) {
|
||||
const bytes = await artifacts.get(
|
||||
row.storageKey,
|
||||
adapterContext(actor, contextBotId, `artifact-get:${row.id}`),
|
||||
);
|
||||
return {
|
||||
id: row.id,
|
||||
botId: row.botId,
|
||||
groupId: row.groupId,
|
||||
runId: row.runId,
|
||||
name: row.name,
|
||||
mimeType: row.mimeType,
|
||||
size: row.size,
|
||||
createdAt: row.createdAt.toISOString(),
|
||||
contentBase64: Buffer.from(bytes).toString("base64"),
|
||||
};
|
||||
}
|
||||
|
||||
type SendAttachmentRow = {
|
||||
id: string;
|
||||
name: string;
|
||||
mimeType: string;
|
||||
size: number;
|
||||
storageKey: string;
|
||||
};
|
||||
|
||||
function normalizeAttachmentIds(artifactIds: string[] | undefined) {
|
||||
const ids = [...new Set(artifactIds ?? [])];
|
||||
if (ids.length > ATTACHMENT_MAX_COUNT) {
|
||||
throw new AttachmentValidationError(`At most ${ATTACHMENT_MAX_COUNT} attachments per message`);
|
||||
}
|
||||
return ids;
|
||||
}
|
||||
|
||||
function toAttachmentResolution<T extends SendAttachmentRow>(ids: string[], rows: T[]) {
|
||||
if (rows.length !== ids.length) throw new IsolationError();
|
||||
const byId = new Map(rows.map((row) => [row.id, row]));
|
||||
const artifacts = ids.map((id) => byId.get(id)!);
|
||||
const blocks = artifacts.map((row) =>
|
||||
messageBlockForArtifact({
|
||||
id: row.id,
|
||||
name: row.name,
|
||||
mimeType: row.mimeType,
|
||||
size: row.size,
|
||||
}),
|
||||
);
|
||||
return { blocks, artifacts };
|
||||
}
|
||||
|
||||
export async function resolveSendAttachments(
|
||||
deps: { prisma: Pick<PrismaClient, "artifact"> },
|
||||
actor: Actor,
|
||||
botId: string,
|
||||
artifactIds: string[] | undefined,
|
||||
) {
|
||||
const ids = normalizeAttachmentIds(artifactIds);
|
||||
if (!ids.length) return toAttachmentResolution(ids, [] as SendAttachmentRow[]);
|
||||
|
||||
const rows = await deps.prisma.artifact.findMany({
|
||||
where: {
|
||||
id: { in: ids },
|
||||
botId,
|
||||
groupId: null,
|
||||
spaceId: actor.spaceId,
|
||||
userId: actor.userId,
|
||||
},
|
||||
});
|
||||
return toAttachmentResolution(ids, rows);
|
||||
}
|
||||
|
||||
export async function resolveGroupSendAttachments(
|
||||
deps: { prisma: Pick<PrismaClient, "artifact"> },
|
||||
actor: Actor,
|
||||
groupId: string,
|
||||
memberBotIds: string[],
|
||||
artifactIds: string[] | undefined,
|
||||
) {
|
||||
const ids = normalizeAttachmentIds(artifactIds);
|
||||
if (!ids.length) return toAttachmentResolution(ids, [] as SendAttachmentRow[]);
|
||||
|
||||
const rows = await deps.prisma.artifact.findMany({
|
||||
where: {
|
||||
id: { in: ids },
|
||||
spaceId: actor.spaceId,
|
||||
userId: actor.userId,
|
||||
OR: [
|
||||
{ groupId },
|
||||
// Accept artifacts uploaded by a current member before group ownership
|
||||
// was persisted. Removing that member revokes this legacy fallback.
|
||||
{ groupId: null, botId: { in: memberBotIds } },
|
||||
],
|
||||
},
|
||||
});
|
||||
return toAttachmentResolution(ids, rows);
|
||||
}
|
||||
|
||||
export function buildUserMessageBlocks(
|
||||
text: string | undefined,
|
||||
attachmentBlocks: ReturnType<typeof messageBlockForArtifact>[],
|
||||
) {
|
||||
const blocks = [];
|
||||
const caption = text?.trim();
|
||||
if (caption) blocks.push({ kind: "text" as const, text: caption });
|
||||
blocks.push(...attachmentBlocks);
|
||||
return blocks;
|
||||
}
|
||||
|
||||
export function buildSendPrompt(
|
||||
text: string | undefined,
|
||||
artifacts: Array<{ name: string; mimeType: string; size: number }>,
|
||||
connectorNames: string[] = [],
|
||||
) {
|
||||
const prompt = promptTextForAttachments(text, artifacts);
|
||||
if (connectorNames.length === 0) return prompt;
|
||||
const marker = "Use these connectors if relevant:";
|
||||
const existing = new RegExp(`^${marker} (.*)\\.$`, "m").exec(prompt);
|
||||
const names = [
|
||||
...new Set([
|
||||
...(existing?.[1]
|
||||
?.split(",")
|
||||
.map((name) => name.trim())
|
||||
.filter(Boolean) ?? []),
|
||||
...connectorNames.map((name) => name.trim()).filter(Boolean),
|
||||
]),
|
||||
];
|
||||
const line = `${marker} ${names.join(", ")}.`;
|
||||
if (existing) return prompt.replace(existing[0], () => line);
|
||||
return prompt ? `${prompt}\n\n${line}` : line;
|
||||
}
|
||||
|
|
@ -0,0 +1,36 @@
|
|||
import { Hono } from "hono";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { mountBotAvatarHttpRoutes } from "./bot-avatar.js";
|
||||
|
||||
describe("bot avatar HTTP routes", () => {
|
||||
it("rejects unauthenticated reads", async () => {
|
||||
const app = new Hono();
|
||||
mountBotAvatarHttpRoutes(
|
||||
app,
|
||||
{ prisma: {} as never, artifacts: {} as never },
|
||||
async () => null,
|
||||
);
|
||||
const response = await app.request("/api/bot-avatars/bot-1");
|
||||
expect(response.status).toBe(401);
|
||||
});
|
||||
|
||||
it("returns 404 when the bot has no custom face", async () => {
|
||||
const app = new Hono();
|
||||
const read = vi.fn(async () => null);
|
||||
mountBotAvatarHttpRoutes(
|
||||
app,
|
||||
{
|
||||
prisma: { bot: { findFirst: read } } as never,
|
||||
artifacts: { get: vi.fn() } as never,
|
||||
},
|
||||
async () => ({
|
||||
userId: "user-1",
|
||||
spaceId: "space-1",
|
||||
email: "user@rakazo.test",
|
||||
isDeploymentOwner: false,
|
||||
}),
|
||||
);
|
||||
const response = await app.request("/api/bot-avatars/bot-1");
|
||||
expect(response.status).toBe(404);
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,30 @@
|
|||
import type { ArtifactStore } from "@rakazo/adapter-kit";
|
||||
import type { Actor } from "@rakazo/contracts";
|
||||
import type { PrismaClient } from "@rakazo/db";
|
||||
import type { Context, Hono } from "hono";
|
||||
import { readBotAvatarImage } from "./artifacts.js";
|
||||
|
||||
export function mountBotAvatarHttpRoutes(
|
||||
app: Hono,
|
||||
deps: { prisma: PrismaClient; artifacts: ArtifactStore },
|
||||
authenticate: (c: Context) => Promise<Actor | null>,
|
||||
) {
|
||||
app.get("/api/bot-avatars/:botId", async (c) => {
|
||||
const actor = await authenticate(c);
|
||||
if (!actor) return c.json({ error: "Unauthorized" }, 401);
|
||||
const botId = c.req.param("botId");
|
||||
if (!botId) return c.json({ error: "Not found" }, 404);
|
||||
const image = await readBotAvatarImage(deps, actor.userId, botId);
|
||||
if (!image) return c.json({ error: "Not found" }, 404);
|
||||
const inm = c.req.header("if-none-match");
|
||||
const etag = `"${image.hash}"`;
|
||||
if (inm === etag) return c.body(null, 304);
|
||||
return new Response(new Uint8Array(image.bytes), {
|
||||
headers: {
|
||||
"content-type": image.mimeType,
|
||||
etag,
|
||||
"cache-control": "private, max-age=3600",
|
||||
},
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
@ -0,0 +1,118 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import { executionBlocksUserTakeover, toComputerStatus } from "./computer-status.js";
|
||||
|
||||
describe("toComputerStatus", () => {
|
||||
it("only marks control that is bound to a waiting run as a requested takeover", () => {
|
||||
const computer = {
|
||||
kind: "fake",
|
||||
state: "running",
|
||||
scope: "team",
|
||||
controlHolder: "user",
|
||||
controlBotId: "bot-1",
|
||||
controlRunId: "run-1",
|
||||
homeRevision: "revision-1",
|
||||
};
|
||||
|
||||
expect(toComputerStatus("bot-1", computer).takeoverRequested).toBe(true);
|
||||
expect(toComputerStatus("bot-1", { ...computer, controlRunId: null }).takeoverRequested).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
it("passes through the busy bot name when takeover is blocked", () => {
|
||||
const computer = {
|
||||
kind: "fake",
|
||||
state: "running",
|
||||
scope: "team",
|
||||
controlHolder: "none",
|
||||
homeRevision: "revision-1",
|
||||
};
|
||||
expect(toComputerStatus("bot-1", computer).busyBotName).toBeNull();
|
||||
expect(toComputerStatus("bot-1", computer, "Writer").busyBotName).toBe("Writer");
|
||||
});
|
||||
|
||||
it("hides update on desktop computers", () => {
|
||||
expect(
|
||||
toComputerStatus("bot-1", {
|
||||
kind: "desktop",
|
||||
state: "running",
|
||||
scope: "team",
|
||||
controlHolder: "none",
|
||||
homeRevision: "r1",
|
||||
}).updateAvailable,
|
||||
).toBe(false);
|
||||
expect(
|
||||
toComputerStatus("bot-1", {
|
||||
kind: "e2b",
|
||||
state: "running",
|
||||
scope: "team",
|
||||
controlHolder: "none",
|
||||
homeRevision: "r1",
|
||||
}).updateAvailable,
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("executionBlocksUserTakeover", () => {
|
||||
const now = Date.parse("2026-08-25T12:00:00.000Z");
|
||||
|
||||
it("allows takeover when there is no execution lease", () => {
|
||||
expect(
|
||||
executionBlocksUserTakeover({
|
||||
hasLease: false,
|
||||
leaseExpiresAt: new Date(now + 60_000),
|
||||
runStatus: "running",
|
||||
now,
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("allows takeover while the run is waiting for the user", () => {
|
||||
expect(
|
||||
executionBlocksUserTakeover({
|
||||
hasLease: true,
|
||||
leaseExpiresAt: new Date(now + 60_000),
|
||||
runStatus: "waiting_takeover",
|
||||
now,
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("blocks takeover for an active lease or active run", () => {
|
||||
expect(
|
||||
executionBlocksUserTakeover({
|
||||
hasLease: true,
|
||||
leaseExpiresAt: new Date(now + 60_000),
|
||||
runStatus: "running",
|
||||
now,
|
||||
}),
|
||||
).toBe(true);
|
||||
expect(
|
||||
executionBlocksUserTakeover({
|
||||
hasLease: true,
|
||||
leaseExpiresAt: new Date(now - 1),
|
||||
runStatus: "running",
|
||||
now,
|
||||
}),
|
||||
).toBe(true);
|
||||
expect(
|
||||
executionBlocksUserTakeover({
|
||||
hasLease: true,
|
||||
leaseExpiresAt: new Date(now + 60_000),
|
||||
runStatus: "completed",
|
||||
now,
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("allows takeover when both the lease and run are inactive", () => {
|
||||
expect(
|
||||
executionBlocksUserTakeover({
|
||||
hasLease: true,
|
||||
leaseExpiresAt: new Date(now - 1),
|
||||
runStatus: "completed",
|
||||
now,
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,89 @@
|
|||
import type { ComputerStatus } from "@rakazo/contracts";
|
||||
import { ACTIVE_RUN_STATUSES, computerScreenSize } from "@rakazo/core";
|
||||
import type { PrismaClient } from "@rakazo/db";
|
||||
|
||||
/** Mirrors computer.takeover: an execution lease blocks user control unless waiting_takeover. */
|
||||
export function executionBlocksUserTakeover(input: {
|
||||
hasLease: boolean;
|
||||
leaseExpiresAt: Date | null | undefined;
|
||||
runStatus: string | null | undefined;
|
||||
now?: number;
|
||||
}): boolean {
|
||||
if (!input.hasLease) return false;
|
||||
if (input.runStatus === "waiting_takeover") return false;
|
||||
const now = input.now ?? Date.now();
|
||||
const leaseActive = Boolean(input.leaseExpiresAt && input.leaseExpiresAt.getTime() > now);
|
||||
const runActive = Boolean(
|
||||
input.runStatus && (ACTIVE_RUN_STATUSES as readonly string[]).includes(input.runStatus),
|
||||
);
|
||||
return leaseActive || runActive;
|
||||
}
|
||||
|
||||
export async function resolveBusyBotName(
|
||||
prisma: PrismaClient,
|
||||
input: {
|
||||
computerId: string | null | undefined;
|
||||
botId: string;
|
||||
botName: string;
|
||||
},
|
||||
): Promise<string | null> {
|
||||
if (!input.computerId) return null;
|
||||
const lease = await prisma.computerExecutionLease.findUnique({
|
||||
where: { computerId_botId: { computerId: input.computerId, botId: input.botId } },
|
||||
select: { expiresAt: true, runId: true },
|
||||
});
|
||||
if (!lease) return null;
|
||||
const run = await prisma.run.findUnique({
|
||||
where: { id: lease.runId },
|
||||
select: { status: true },
|
||||
});
|
||||
return executionBlocksUserTakeover({
|
||||
hasLease: true,
|
||||
leaseExpiresAt: lease.expiresAt,
|
||||
runStatus: run?.status,
|
||||
})
|
||||
? input.botName
|
||||
: null;
|
||||
}
|
||||
|
||||
export function toComputerStatus(
|
||||
botId: string,
|
||||
computer: {
|
||||
kind: string;
|
||||
state: string;
|
||||
scope: string;
|
||||
controlHolder: string;
|
||||
controlBotId?: string | null;
|
||||
controlRunId?: string | null;
|
||||
homeRevision: string;
|
||||
} | null,
|
||||
busyBotName: string | null = null,
|
||||
): ComputerStatus {
|
||||
const state =
|
||||
computer?.state === "suspending"
|
||||
? "running"
|
||||
: computer?.state === "stopped" ||
|
||||
computer?.state === "booting" ||
|
||||
computer?.state === "running" ||
|
||||
computer?.state === "suspended" ||
|
||||
computer?.state === "error"
|
||||
? computer.state
|
||||
: "stopped";
|
||||
const screen = computerScreenSize(computer?.kind);
|
||||
const kind = (computer?.kind ?? "fake") as ComputerStatus["kind"];
|
||||
return {
|
||||
botId,
|
||||
mode: computer?.scope === "dedicated" ? "dedicated" : "team",
|
||||
kind,
|
||||
state,
|
||||
controlHolder: (computer?.controlHolder ?? "none") as ComputerStatus["controlHolder"],
|
||||
controlBotId: computer?.controlBotId ?? null,
|
||||
takeoverRequested: Boolean(computer?.controlRunId),
|
||||
screenAvailable: state === "running" || state === "booting",
|
||||
screenWidth: screen.width,
|
||||
screenHeight: screen.height,
|
||||
homeRevision: computer?.homeRevision ?? null,
|
||||
busyBotName,
|
||||
updateAvailable: kind !== "desktop",
|
||||
};
|
||||
}
|
||||
|
|
@ -0,0 +1,192 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import { loadEnv } from "./env.js";
|
||||
|
||||
const base = {
|
||||
DATABASE_URL: "postgres://rakazo:rakazo@127.0.0.1:5433/rakazo",
|
||||
NODE_ENV: "test",
|
||||
};
|
||||
|
||||
describe("loadEnv", () => {
|
||||
it("defaults the product path to Pi, Docker, and Graphile Worker", () => {
|
||||
const env = loadEnv(base);
|
||||
expect(env.agentRuntime).toBe("pi");
|
||||
expect(env.sandboxProvider).toBe("docker");
|
||||
expect(env.wakeupDriver).toBe("graphile");
|
||||
expect(env.apiHost).toBe("127.0.0.1");
|
||||
expect(env.nodeEnv).toBe("test");
|
||||
});
|
||||
|
||||
it("keeps explicit emulator settings for pnpm test", () => {
|
||||
const env = loadEnv({
|
||||
...base,
|
||||
AGENT_RUNTIME: "scripted",
|
||||
SANDBOX_PROVIDER: "fake",
|
||||
WAKEUP_DRIVER: "memory",
|
||||
});
|
||||
expect(env.agentRuntime).toBe("scripted");
|
||||
expect(env.sandboxProvider).toBe("fake");
|
||||
expect(env.wakeupDriver).toBe("memory");
|
||||
});
|
||||
|
||||
it("falls back to none when a remote provider key is missing", () => {
|
||||
expect(
|
||||
loadEnv({
|
||||
...base,
|
||||
SANDBOX_PROVIDER: "e2b",
|
||||
}).sandboxProvider,
|
||||
).toBe("none");
|
||||
expect(
|
||||
loadEnv({
|
||||
...base,
|
||||
SANDBOX_PROVIDER: "none",
|
||||
}).sandboxProvider,
|
||||
).toBe("none");
|
||||
expect(
|
||||
loadEnv({
|
||||
...base,
|
||||
SANDBOX_PROVIDER: "",
|
||||
}).sandboxProvider,
|
||||
).toBe("none");
|
||||
});
|
||||
|
||||
it("loads provider-specific Daytona configuration", () => {
|
||||
const env = loadEnv({
|
||||
...base,
|
||||
SANDBOX_PROVIDER: "daytona",
|
||||
DAYTONA_API_KEY: "test-daytona-key",
|
||||
DAYTONA_API_URL: "https://daytona.test/api",
|
||||
DAYTONA_TARGET: "test-target",
|
||||
});
|
||||
expect(env).toMatchObject({
|
||||
sandboxProvider: "daytona",
|
||||
daytonaApiKey: "test-daytona-key",
|
||||
daytonaApiUrl: "https://daytona.test/api",
|
||||
daytonaTarget: "test-target",
|
||||
});
|
||||
});
|
||||
|
||||
it("loads provider-specific Box configuration", () => {
|
||||
const env = loadEnv({
|
||||
...base,
|
||||
SANDBOX_PROVIDER: "box",
|
||||
BOX_API_KEY: "test-box-key",
|
||||
BOX_API_URL: "https://box.test/api/v1",
|
||||
});
|
||||
expect(env).toMatchObject({
|
||||
sandboxProvider: "box",
|
||||
boxApiKey: "test-box-key",
|
||||
boxApiUrl: "https://box.test/api/v1",
|
||||
});
|
||||
});
|
||||
|
||||
it("throws when production omits secrets", () => {
|
||||
expect(() =>
|
||||
loadEnv({
|
||||
DATABASE_URL: base.DATABASE_URL,
|
||||
NODE_ENV: "production",
|
||||
}),
|
||||
).toThrow(/BETTER_AUTH_SECRET/);
|
||||
});
|
||||
|
||||
it("throws when production uses placeholder secrets", () => {
|
||||
expect(() =>
|
||||
loadEnv({
|
||||
DATABASE_URL: base.DATABASE_URL,
|
||||
NODE_ENV: "production",
|
||||
BETTER_AUTH_SECRET: "dev-secret-change-me-please-32chars",
|
||||
ENCRYPTION_KEY: "real-encryption-key-value",
|
||||
SANDBOX_SUPERVISOR_TOKEN: "real-supervisor-token-with-enough-length",
|
||||
SCREEN_PROXY_SECRET: "real-screen-proxy-secret-with-enough-length",
|
||||
}),
|
||||
).toThrow(/BETTER_AUTH_SECRET/);
|
||||
});
|
||||
|
||||
it("loads real secrets in production", () => {
|
||||
const env = loadEnv({
|
||||
DATABASE_URL: base.DATABASE_URL,
|
||||
NODE_ENV: "production",
|
||||
BETTER_AUTH_SECRET: "prod-auth-secret-with-enough-length",
|
||||
ENCRYPTION_KEY: "prod-encryption-key-with-enough-length",
|
||||
SCREEN_PROXY_SECRET: "prod-screen-proxy-secret-with-enough-length",
|
||||
SANDBOX_PROVIDER: "e2b",
|
||||
API_HOST: "0.0.0.0",
|
||||
});
|
||||
expect(env.authSecret).toBe("prod-auth-secret-with-enough-length");
|
||||
expect(env.encryptionKey).toBe("prod-encryption-key-with-enough-length");
|
||||
expect(env.sandboxSupervisorToken).toBeUndefined();
|
||||
expect(env.screenProxySecret).toBe("prod-screen-proxy-secret-with-enough-length");
|
||||
expect(env.apiHost).toBe("0.0.0.0");
|
||||
});
|
||||
|
||||
it("falls back to none in production when Docker has no supervisor token", () => {
|
||||
const env = loadEnv({
|
||||
DATABASE_URL: base.DATABASE_URL,
|
||||
NODE_ENV: "production",
|
||||
BETTER_AUTH_SECRET: "prod-auth-secret-with-enough-length",
|
||||
ENCRYPTION_KEY: "prod-encryption-key-with-enough-length",
|
||||
SCREEN_PROXY_SECRET: "prod-screen-proxy-secret-with-enough-length",
|
||||
SANDBOX_PROVIDER: "docker",
|
||||
});
|
||||
expect(env.sandboxProvider).toBe("none");
|
||||
expect(env.sandboxSupervisorToken).toBeUndefined();
|
||||
});
|
||||
|
||||
it("requires a dedicated supervisor token when Docker stays selected", () => {
|
||||
expect(() =>
|
||||
loadEnv({
|
||||
DATABASE_URL: base.DATABASE_URL,
|
||||
NODE_ENV: "production",
|
||||
BETTER_AUTH_SECRET: "prod-auth-secret-with-enough-length",
|
||||
ENCRYPTION_KEY: "prod-encryption-key-with-enough-length",
|
||||
SCREEN_PROXY_SECRET: "prod-screen-proxy-secret-with-enough-length",
|
||||
SANDBOX_PROVIDER: "docker",
|
||||
SANDBOX_SUPERVISOR_TOKEN: "too-short",
|
||||
}),
|
||||
).toThrow(/SANDBOX_SUPERVISOR_TOKEN/);
|
||||
});
|
||||
|
||||
it("exposes a deployed git revision when GIT_SHA is set", () => {
|
||||
expect(loadEnv(base).gitSha).toBeUndefined();
|
||||
expect(loadEnv({ ...base, GIT_SHA: " 3c6e209 " }).gitSha).toBe("3c6e209");
|
||||
expect(loadEnv({ ...base, RAKAZO_GIT_SHA: "abc1234" }).gitSha).toBe("abc1234");
|
||||
});
|
||||
|
||||
it("loads optional updater sidecar wiring without requiring the token at boot", () => {
|
||||
expect(loadEnv(base).updaterUrl).toBeUndefined();
|
||||
expect(loadEnv(base).updaterToken).toBeUndefined();
|
||||
const env = loadEnv({
|
||||
...base,
|
||||
RAKAZO_UPDATER_URL: " http://updater:7092 ",
|
||||
RAKAZO_UPDATER_TOKEN: " fake-review-updater-token-000000000000 ",
|
||||
});
|
||||
expect(env.updaterUrl).toBe("http://updater:7092");
|
||||
expect(env.updaterToken).toBe("fake-review-updater-token-000000000000");
|
||||
});
|
||||
|
||||
it("loads SMTP configuration and keeps the email emulator out of production", () => {
|
||||
expect(
|
||||
loadEnv({
|
||||
...base,
|
||||
SMTP_URL: " smtps://user:secret@smtp.example.test:465 ",
|
||||
EMAIL_FROM: " BangSo Bot <no-reply@example.test> ",
|
||||
EMAIL_EMULATOR: "true",
|
||||
}),
|
||||
).toMatchObject({
|
||||
smtpUrl: "smtps://user:secret@smtp.example.test:465",
|
||||
emailFrom: "BangSo Bot <no-reply@example.test>",
|
||||
emailEmulator: true,
|
||||
});
|
||||
expect(
|
||||
loadEnv({
|
||||
...base,
|
||||
NODE_ENV: "production",
|
||||
BETTER_AUTH_SECRET: "prod-auth-secret-with-enough-length",
|
||||
ENCRYPTION_KEY: "prod-encryption-key-with-enough-length",
|
||||
SCREEN_PROXY_SECRET: "prod-screen-proxy-secret-with-enough-length",
|
||||
SANDBOX_PROVIDER: "none",
|
||||
EMAIL_EMULATOR: "true",
|
||||
}).emailEmulator,
|
||||
).toBe(false);
|
||||
expect(loadEnv({ ...base, NODE_ENV: "development" }).nodeEnv).toBe("development");
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,153 @@
|
|||
import { resolveDeploymentModel, resolveSandboxProvider } from "@rakazo/adapters";
|
||||
import {
|
||||
resolveAuthSecret,
|
||||
resolveEncryptionKey,
|
||||
resolveScreenProxySecret,
|
||||
resolveSupervisorToken,
|
||||
} from "@rakazo/core";
|
||||
|
||||
export { resolveSandboxProvider } from "@rakazo/adapters";
|
||||
|
||||
export interface AppEnv {
|
||||
nodeEnv: string;
|
||||
databaseUrl: string;
|
||||
realtimeDatabaseUrl: string;
|
||||
authSecret: string;
|
||||
authUrl: string;
|
||||
webOrigin: string;
|
||||
apiUrl: string;
|
||||
apiHost: string;
|
||||
signupsEnabled: string | undefined;
|
||||
signupAllowlist: string | undefined;
|
||||
encryptionKey: string;
|
||||
dataDir: string;
|
||||
sandboxSupervisorUrl: string;
|
||||
sandboxSupervisorToken: string | undefined;
|
||||
screenProxySecret: string;
|
||||
sandboxProvider: string;
|
||||
agentRuntime: string;
|
||||
deploymentModelKey: string | undefined;
|
||||
e2bApiKey: string | undefined;
|
||||
daytonaApiKey: string | undefined;
|
||||
daytonaApiUrl: string | undefined;
|
||||
daytonaTarget: string | undefined;
|
||||
boxApiKey: string | undefined;
|
||||
boxApiUrl: string | undefined;
|
||||
composioApiKey: string | undefined;
|
||||
pipedreamClientId: string | undefined;
|
||||
pipedreamClientSecret: string | undefined;
|
||||
pipedreamProjectId: string | undefined;
|
||||
pipedreamEnvironment: "development" | "production";
|
||||
sendblueApiKeyId: string | undefined;
|
||||
sendblueApiSecret: string | undefined;
|
||||
sendblueSigningSecret: string | undefined;
|
||||
sendbluePhoneNumber: string | undefined;
|
||||
smtpUrl: string | undefined;
|
||||
emailFrom: string | undefined;
|
||||
emailEmulator: boolean;
|
||||
slackBotToken: string | undefined;
|
||||
slackSigningSecret: string | undefined;
|
||||
whatsappAccessToken: string | undefined;
|
||||
whatsappPhoneNumberId: string | undefined;
|
||||
whatsappAppSecret: string | undefined;
|
||||
whatsappVerifyToken: string | undefined;
|
||||
telegramBotToken: string | undefined;
|
||||
telegramWebhookSecret: string | undefined;
|
||||
/** Unknown chat senders auto-provision their own accounts when true. */
|
||||
messagingOpenSignup: boolean;
|
||||
defaultProvider: string;
|
||||
defaultModel: string;
|
||||
wakeupDriver: string;
|
||||
mcpStdioEnabled: boolean;
|
||||
mcpStdioAllowedCommands: string[];
|
||||
port: number;
|
||||
gitSha: string | undefined;
|
||||
/** Private Compose control-network URL for the opt-in updater sidecar. */
|
||||
updaterUrl: string | undefined;
|
||||
/** Bearer shared with the updater; never sent to the browser. */
|
||||
updaterToken: string | undefined;
|
||||
/** Current application image tag; used for compose manual-upgrade command selection. */
|
||||
imageTag: string | undefined;
|
||||
}
|
||||
|
||||
export function loadEnv(source: NodeJS.ProcessEnv = process.env): AppEnv {
|
||||
const authSecret = resolveAuthSecret(source);
|
||||
const sandboxProvider = resolveSandboxProvider(source);
|
||||
const deploymentModel = resolveDeploymentModel(source);
|
||||
const updaterUrl = optional(source.RAKAZO_UPDATER_URL);
|
||||
const updaterToken = optional(source.RAKAZO_UPDATER_TOKEN);
|
||||
return {
|
||||
nodeEnv: source.NODE_ENV ?? "",
|
||||
databaseUrl: required(source, "DATABASE_URL"),
|
||||
realtimeDatabaseUrl: source.REALTIME_DATABASE_URL ?? required(source, "DATABASE_URL"),
|
||||
authSecret,
|
||||
authUrl: source.BETTER_AUTH_URL ?? source.WEB_ORIGIN ?? "http://127.0.0.1:5173",
|
||||
webOrigin: source.WEB_ORIGIN ?? "http://127.0.0.1:5173",
|
||||
apiUrl: source.API_URL ?? "http://127.0.0.1:3100",
|
||||
apiHost: source.API_HOST ?? "127.0.0.1",
|
||||
signupsEnabled: source.SIGNUPS_ENABLED,
|
||||
signupAllowlist: source.SIGNUP_ALLOWLIST,
|
||||
encryptionKey: resolveEncryptionKey(source),
|
||||
dataDir: source.DATA_DIR ?? "./data",
|
||||
sandboxSupervisorUrl: source.SANDBOX_SUPERVISOR_URL ?? "http://127.0.0.1:7091",
|
||||
sandboxSupervisorToken:
|
||||
sandboxProvider === "docker" ? resolveSupervisorToken(source) : undefined,
|
||||
screenProxySecret: resolveScreenProxySecret(source),
|
||||
sandboxProvider,
|
||||
agentRuntime: source.AGENT_RUNTIME ?? "pi",
|
||||
// Provider, model and key resolve together: see resolveDeploymentModel.
|
||||
deploymentModelKey: deploymentModel.key,
|
||||
e2bApiKey: source.E2B_API_KEY,
|
||||
daytonaApiKey: source.DAYTONA_API_KEY,
|
||||
daytonaApiUrl: source.DAYTONA_API_URL,
|
||||
daytonaTarget: source.DAYTONA_TARGET,
|
||||
boxApiKey: source.BOX_API_KEY,
|
||||
boxApiUrl: source.BOX_API_URL ?? source.BOX_BASE_URL,
|
||||
composioApiKey: source.COMPOSIO_API_KEY,
|
||||
pipedreamClientId: optional(source.PIPEDREAM_CLIENT_ID),
|
||||
pipedreamClientSecret: optional(source.PIPEDREAM_CLIENT_SECRET),
|
||||
pipedreamProjectId: optional(source.PIPEDREAM_PROJECT_ID),
|
||||
pipedreamEnvironment:
|
||||
source.PIPEDREAM_ENVIRONMENT === "production" ? "production" : "development",
|
||||
sendblueApiKeyId: optional(source.SENDBLUE_API_KEY_ID),
|
||||
sendblueApiSecret: optional(source.SENDBLUE_API_SECRET),
|
||||
sendblueSigningSecret: optional(source.SENDBLUE_SIGNING_SECRET),
|
||||
sendbluePhoneNumber: optional(source.SENDBLUE_PHONE_NUMBER),
|
||||
smtpUrl: optional(source.SMTP_URL),
|
||||
emailFrom: optional(source.EMAIL_FROM),
|
||||
emailEmulator: source.EMAIL_EMULATOR === "true" && source.NODE_ENV !== "production",
|
||||
slackBotToken: optional(source.SLACK_BOT_TOKEN),
|
||||
slackSigningSecret: optional(source.SLACK_SIGNING_SECRET),
|
||||
whatsappAccessToken: optional(source.WHATSAPP_ACCESS_TOKEN),
|
||||
whatsappPhoneNumberId: optional(source.WHATSAPP_PHONE_NUMBER_ID),
|
||||
whatsappAppSecret: optional(source.WHATSAPP_APP_SECRET),
|
||||
whatsappVerifyToken: optional(source.WHATSAPP_VERIFY_TOKEN),
|
||||
telegramBotToken: optional(source.TELEGRAM_BOT_TOKEN),
|
||||
telegramWebhookSecret: optional(source.TELEGRAM_WEBHOOK_SECRET_TOKEN),
|
||||
messagingOpenSignup: source.MESSAGING_OPEN_SIGNUP === "true",
|
||||
defaultProvider: deploymentModel.provider,
|
||||
defaultModel: deploymentModel.model,
|
||||
wakeupDriver: source.WAKEUP_DRIVER ?? "graphile",
|
||||
mcpStdioEnabled: source.MCP_STDIO_ENABLED === "true",
|
||||
mcpStdioAllowedCommands: (source.MCP_STDIO_ALLOWED_COMMANDS ?? "")
|
||||
.split(",")
|
||||
.map((value) => value.trim())
|
||||
.filter(Boolean),
|
||||
port: Number(source.API_PORT ?? 3100),
|
||||
gitSha: optional(source.GIT_SHA) ?? optional(source.RAKAZO_GIT_SHA),
|
||||
updaterUrl,
|
||||
updaterToken,
|
||||
imageTag: optional(source.RAKAZO_IMAGE_TAG),
|
||||
};
|
||||
}
|
||||
|
||||
function required(source: NodeJS.ProcessEnv, key: string): string {
|
||||
const value = source[key];
|
||||
if (!value) throw new Error(`Missing ${key}`);
|
||||
return value;
|
||||
}
|
||||
|
||||
function optional(value: string | undefined): string | undefined {
|
||||
const trimmed = value?.trim();
|
||||
return trimmed || undefined;
|
||||
}
|
||||
|
|
@ -0,0 +1,39 @@
|
|||
import type { Socket } from "node:net";
|
||||
import { loadRootEnv } from "@rakazo/core/node/load-root-env";
|
||||
|
||||
loadRootEnv();
|
||||
|
||||
import { serve } from "@hono/node-server";
|
||||
import { createApp } from "./app.js";
|
||||
import { loadEnv } from "./env.js";
|
||||
|
||||
const env = loadEnv();
|
||||
const { app, stop } = await createApp(env);
|
||||
const server = serve({ fetch: app.fetch, port: env.port, hostname: env.apiHost }, () => {
|
||||
console.log(`rakazo api on http://${env.apiHost}:${env.port}`);
|
||||
});
|
||||
|
||||
// Long-lived connections (threads.subscribe SSE streams) never end on their
|
||||
// own, so server.close() alone waits forever for them. Track sockets and
|
||||
// force-close any still open after a short grace period for in-flight
|
||||
// requests, or every restart/shutdown hangs until something force-kills it.
|
||||
const sockets = new Set<Socket>();
|
||||
server.on("connection", (socket) => {
|
||||
sockets.add(socket);
|
||||
socket.once("close", () => sockets.delete(socket));
|
||||
});
|
||||
|
||||
let stopping = false;
|
||||
const shutdown = async () => {
|
||||
if (stopping) return;
|
||||
stopping = true;
|
||||
const closed = new Promise<void>((resolve) => server.close(() => resolve()));
|
||||
const grace = setTimeout(() => {
|
||||
for (const socket of sockets) socket.destroy();
|
||||
}, 2_000);
|
||||
await closed;
|
||||
clearTimeout(grace);
|
||||
await stop();
|
||||
};
|
||||
process.once("SIGTERM", () => void shutdown());
|
||||
process.once("SIGINT", () => void shutdown());
|
||||
|
|
@ -0,0 +1,36 @@
|
|||
import { ORPCError } from "@orpc/server";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { logUnexpectedRpcError } from "./app.js";
|
||||
|
||||
describe("logUnexpectedRpcError", () => {
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("stays quiet for an error the router chose to return", () => {
|
||||
const logError = vi.spyOn(console, "error").mockImplementation(() => undefined);
|
||||
|
||||
logUnexpectedRpcError(new ORPCError("BAD_REQUEST", { message: "file is too large" }), [
|
||||
"computer",
|
||||
"readFile",
|
||||
]);
|
||||
|
||||
expect(logError).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("names the procedure and every cause behind an opaque failure", () => {
|
||||
const logError = vi.spyOn(console, "error").mockImplementation(() => undefined);
|
||||
const error = new Error("fetch failed", {
|
||||
cause: Object.assign(new Error("connect ECONNREFUSED 127.0.0.1:7091"), {
|
||||
code: "ECONNREFUSED",
|
||||
}),
|
||||
});
|
||||
|
||||
logUnexpectedRpcError(error, ["computer", "screenUrl"]);
|
||||
|
||||
const logged = logError.mock.calls[0]?.join(" ") ?? "";
|
||||
expect(logged).toContain("rpc computer/screenUrl failed");
|
||||
expect(logged).toContain("fetch failed");
|
||||
expect(logged).toContain("connect ECONNREFUSED 127.0.0.1:7091");
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,130 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import { buildMcpUpdateMaterial } from "./mcp-material.js";
|
||||
|
||||
describe("buildMcpUpdateMaterial", () => {
|
||||
it("keeps the stored blob untouched when the update carries no credential data", () => {
|
||||
expect(
|
||||
buildMcpUpdateMaterial(
|
||||
{},
|
||||
{
|
||||
transport: "streamable_http",
|
||||
slug: "x",
|
||||
name: "x",
|
||||
endpoint: "https://mcp.example.test",
|
||||
headers: {},
|
||||
},
|
||||
),
|
||||
).toEqual({ action: "keep" });
|
||||
});
|
||||
|
||||
it("stores a merged blob when a new secret is supplied, preserving OAuth state", () => {
|
||||
const existing = { secret: "old-token", oauth: { tokens: { access_token: "t" } } };
|
||||
const result = buildMcpUpdateMaterial(existing, {
|
||||
transport: "streamable_http",
|
||||
slug: "x",
|
||||
name: "x",
|
||||
endpoint: "https://mcp.example.test",
|
||||
headers: {},
|
||||
secret: "new-token",
|
||||
});
|
||||
expect(result).toEqual({
|
||||
action: "store",
|
||||
material: { secret: "new-token", oauth: { tokens: { access_token: "t" } }, headers: {} },
|
||||
});
|
||||
});
|
||||
|
||||
it("persists env/headers even without any static secret (no silent drop)", () => {
|
||||
const result = buildMcpUpdateMaterial(
|
||||
{},
|
||||
{
|
||||
transport: "stdio",
|
||||
slug: "x",
|
||||
name: "x",
|
||||
command: "/bin/mcp",
|
||||
env: { API_KEY: "k" },
|
||||
},
|
||||
);
|
||||
expect(result).toEqual({ action: "store", material: { env: { API_KEY: "k" } } });
|
||||
});
|
||||
|
||||
it("clearing removes static credentials but keeps OAuth state", () => {
|
||||
const existing = { secret: "token", env: { A: "b" }, oauth: { tokens: { access_token: "t" } } };
|
||||
const result = buildMcpUpdateMaterial(existing, {
|
||||
transport: "streamable_http",
|
||||
slug: "x",
|
||||
name: "x",
|
||||
endpoint: "https://mcp.example.test",
|
||||
headers: {},
|
||||
clearCredential: true,
|
||||
});
|
||||
expect(result).toEqual({
|
||||
action: "store",
|
||||
material: { oauth: { tokens: { access_token: "t" } } },
|
||||
});
|
||||
});
|
||||
|
||||
it("clearing with no OAuth state yields an empty blob so the caller can delete the secret row", () => {
|
||||
const result = buildMcpUpdateMaterial(
|
||||
{ secret: "token" },
|
||||
{
|
||||
transport: "streamable_http",
|
||||
slug: "x",
|
||||
name: "x",
|
||||
endpoint: "https://mcp.example.test",
|
||||
headers: {},
|
||||
clearCredential: true,
|
||||
},
|
||||
);
|
||||
expect(result).toEqual({ action: "store", material: {} });
|
||||
});
|
||||
|
||||
it("clears endpoint-bound OAuth state while preserving static credentials", () => {
|
||||
const result = buildMcpUpdateMaterial(
|
||||
{ secret: "token", oauth: { tokens: { access_token: "endpoint-token" } } },
|
||||
{
|
||||
transport: "streamable_http",
|
||||
slug: "x",
|
||||
name: "x",
|
||||
endpoint: "https://new-mcp.example.test",
|
||||
headers: {},
|
||||
},
|
||||
{ clearOAuth: true },
|
||||
);
|
||||
expect(result).toEqual({
|
||||
action: "store",
|
||||
material: { secret: "token", headers: {} },
|
||||
});
|
||||
});
|
||||
|
||||
it("deletes an OAuth-only blob when its endpoint changes", () => {
|
||||
const result = buildMcpUpdateMaterial(
|
||||
{ oauth: { tokens: { access_token: "endpoint-token" } } },
|
||||
{
|
||||
transport: "streamable_http",
|
||||
slug: "x",
|
||||
name: "x",
|
||||
endpoint: "https://new-mcp.example.test",
|
||||
headers: {},
|
||||
},
|
||||
{ clearOAuth: true },
|
||||
);
|
||||
expect(result).toEqual({ action: "store", material: {} });
|
||||
});
|
||||
|
||||
it("replaces headers on update and leaves env untouched when the transport cannot express it", () => {
|
||||
const result = buildMcpUpdateMaterial(
|
||||
{ secret: "s", env: { OLD: "x" }, headers: { Authorization: "a" } },
|
||||
{
|
||||
transport: "streamable_http",
|
||||
slug: "x",
|
||||
name: "x",
|
||||
endpoint: "https://mcp.example.test",
|
||||
headers: { Authorization: "b" },
|
||||
},
|
||||
);
|
||||
expect(result).toEqual({
|
||||
action: "store",
|
||||
material: { secret: "s", env: { OLD: "x" }, headers: { Authorization: "b" } },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,63 @@
|
|||
import type { McpServerConfigInput } from "@rakazo/contracts";
|
||||
|
||||
/** Shape of the encrypted MCP credential blob. `oauth` holds SDK OAuth state
|
||||
* (tokens, client registration, PKCE verifier) managed by McpOAuthBroker. */
|
||||
export type McpSecretMaterial = {
|
||||
secret?: string;
|
||||
env?: Record<string, string>;
|
||||
headers?: Record<string, string>;
|
||||
oauth?: Record<string, unknown>;
|
||||
};
|
||||
|
||||
export type McpMaterialUpdate =
|
||||
| { action: "keep" }
|
||||
| { action: "store"; material: McpSecretMaterial };
|
||||
|
||||
/** Compute the next encrypted credential blob for an MCP server update.
|
||||
*
|
||||
* - "keep": the update carries no credential data; leave the stored blob as is.
|
||||
* - "store": rewrite the blob. An empty material means credentials were
|
||||
* cleared entirely — the caller should delete the secret row and null the
|
||||
* server's secretId instead of storing an empty object.
|
||||
*
|
||||
* env/headers use full-replace semantics (the update payload is the complete
|
||||
* set), matching the create handler. OAuth state is preserved unless the
|
||||
* endpoint changed, since tokens issued for one endpoint must never be sent to
|
||||
* another server. */
|
||||
export function buildMcpUpdateMaterial(
|
||||
existing: McpSecretMaterial,
|
||||
config: McpServerConfigInput,
|
||||
options: { clearOAuth?: boolean } = {},
|
||||
): McpMaterialUpdate {
|
||||
const material = options.clearOAuth ? { ...existing } : existing;
|
||||
const clearedOAuth = options.clearOAuth === true && material.oauth !== undefined;
|
||||
if (options.clearOAuth) delete material.oauth;
|
||||
const clearing = config.clearCredential === true;
|
||||
if (clearing) {
|
||||
return { action: "store", material: material.oauth ? { oauth: material.oauth } : {} };
|
||||
}
|
||||
const secret = "secret" in config && config.secret ? config.secret : undefined;
|
||||
const env = "env" in config ? config.env : undefined;
|
||||
const headers = "headers" in config ? config.headers : undefined;
|
||||
const existingHasMaterial = Boolean(
|
||||
material.secret ||
|
||||
(material.env && Object.keys(material.env).length > 0) ||
|
||||
(material.headers && Object.keys(material.headers).length > 0) ||
|
||||
material.oauth,
|
||||
);
|
||||
const suppliesMaterial = Boolean(
|
||||
secret || (env && Object.keys(env).length > 0) || (headers && Object.keys(headers).length > 0),
|
||||
);
|
||||
if (!existingHasMaterial && !suppliesMaterial) {
|
||||
return clearedOAuth ? { action: "store", material } : { action: "keep" };
|
||||
}
|
||||
return {
|
||||
action: "store",
|
||||
material: {
|
||||
...material,
|
||||
...(secret ? { secret } : {}),
|
||||
...(env !== undefined ? { env } : {}),
|
||||
...(headers !== undefined ? { headers } : {}),
|
||||
},
|
||||
};
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
|
|
@ -0,0 +1,530 @@
|
|||
import type { JobPublisher, MessagingInboundMessage } from "@rakazo/adapter-kit";
|
||||
import { messagingDeliverJob, runContinueJob } from "@rakazo/adapter-kit";
|
||||
import type { MessageBlock } from "@rakazo/contracts";
|
||||
import { parseMessagingCommand, sanitizeMessagingLabel } from "@rakazo/core";
|
||||
import type {
|
||||
MessagingIdentityRequest,
|
||||
Prisma,
|
||||
PrismaClient,
|
||||
ProvisionedMessagingIdentity,
|
||||
SignupPolicyEnv,
|
||||
ThreadEvents,
|
||||
} from "@rakazo/db";
|
||||
import {
|
||||
createThreadMessage,
|
||||
normalizeMessagingLinkCode,
|
||||
redeemMessagingLinkCode,
|
||||
} from "@rakazo/db";
|
||||
|
||||
export interface MessagingInboundDeps {
|
||||
prisma: PrismaClient;
|
||||
events: Pick<ThreadEvents, "sendUserMessage" | "notify">;
|
||||
jobs: Pick<JobPublisher, "enqueue">;
|
||||
provision: (
|
||||
request: MessagingIdentityRequest,
|
||||
env: SignupPolicyEnv,
|
||||
) => Promise<ProvisionedMessagingIdentity>;
|
||||
/**
|
||||
* Poke-style open line: unknown senders auto-provision their own account.
|
||||
* Off by default — strangers' runs would bill the deployment model key.
|
||||
*/
|
||||
openSignup: boolean;
|
||||
signupPolicy: SignupPolicyEnv;
|
||||
/**
|
||||
* Best-effort "…" bubbles shown to a 1:1 sender while their run executes.
|
||||
* Cosmetic only — callers must catch failures; groups never get it.
|
||||
*/
|
||||
typing?: (threadId: string) => Promise<void>;
|
||||
}
|
||||
|
||||
type IdentityRow = {
|
||||
id: string;
|
||||
provider: string;
|
||||
address: string;
|
||||
userId: string;
|
||||
spaceId: string;
|
||||
botId: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Inbound routing. 1:1 messages are messages to the sender's own bot (with
|
||||
* provisioning on first contact and the YES/NO/LEAVE owner commands).
|
||||
* Group messages drive channel discovery — upsert channel + members, DM
|
||||
* invites to linked owners, one intro when strangers are present — and
|
||||
* fan out to every approved member bot's own thread.
|
||||
*/
|
||||
export function createMessagingInboundHandler(deps: MessagingInboundDeps) {
|
||||
return async (event: MessagingInboundMessage): Promise<void> => {
|
||||
if (!event.isDirect) {
|
||||
await handleChannelEvent(deps, event);
|
||||
return;
|
||||
}
|
||||
await handleDirectEvent(deps, event);
|
||||
};
|
||||
}
|
||||
|
||||
async function handleDirectEvent(
|
||||
deps: MessagingInboundDeps,
|
||||
event: MessagingInboundMessage,
|
||||
): Promise<void> {
|
||||
// Inbound media arrives as a CDN URL (often expiring); no artifact
|
||||
// ingestion in v1, so it rides along as text.
|
||||
const text = [event.content, event.mediaUrl].filter(Boolean).join("\n");
|
||||
|
||||
const where = { provider_address: { provider: event.provider, address: event.from } } as const;
|
||||
const existing = await deps.prisma.messagingIdentity.findUnique({ where });
|
||||
if (existing) {
|
||||
// Any reply — even a content-free reaction — ends the consecutive-
|
||||
// outbound streak, but only real text wakes the bot. The conversation
|
||||
// id is refreshed from the webhook so outbound always has a thread.
|
||||
await deps.prisma.messagingIdentity.update({
|
||||
where: { id: existing.id },
|
||||
data: { outboundSinceInbound: 0, lastInboundAt: new Date(), dmThreadId: event.threadId },
|
||||
});
|
||||
if (!text) return;
|
||||
// Owner commands are only parsed in the verified 1:1 conversation.
|
||||
const command = parseMessagingCommand(event.content);
|
||||
if (command && (await applyOwnerCommand(deps, existing, command))) return;
|
||||
// A linked sender pasting a fresh code re-points this address at another
|
||||
// of their bots (only their own codes apply).
|
||||
if (await tryRedeemLinkCode(deps, event)) return;
|
||||
} else {
|
||||
// Unlinked senders: a valid link code binds this address to its issuer's
|
||||
// account and bot; otherwise the line is silent unless the deployment
|
||||
// explicitly runs as an open Poke-style signup line.
|
||||
if (await tryRedeemLinkCode(deps, event)) return;
|
||||
if (!deps.openSignup) return;
|
||||
// Never provision a full account for a reaction or empty payload.
|
||||
if (!text) return;
|
||||
}
|
||||
|
||||
let ids: ProvisionedMessagingIdentity;
|
||||
if (existing) {
|
||||
const thread = await deps.prisma.thread.findFirst({ where: { botId: existing.botId } });
|
||||
if (!thread) throw new Error(`messaging identity ${existing.id} has no thread`);
|
||||
ids = {
|
||||
provider: existing.provider,
|
||||
address: existing.address,
|
||||
userId: existing.userId,
|
||||
spaceId: existing.spaceId,
|
||||
botId: existing.botId,
|
||||
threadId: thread.id,
|
||||
created: false,
|
||||
};
|
||||
} else {
|
||||
ids = await deps.provision(
|
||||
{
|
||||
provider: event.provider,
|
||||
address: event.from,
|
||||
dmThreadId: event.threadId,
|
||||
displayName: event.fromLabel ? sanitizeMessagingLabel(event.fromLabel) : null,
|
||||
},
|
||||
deps.signupPolicy,
|
||||
);
|
||||
}
|
||||
|
||||
const sent = await deps.events.sendUserMessage({
|
||||
spaceId: ids.spaceId,
|
||||
threadId: ids.threadId,
|
||||
botId: ids.botId,
|
||||
userId: ids.userId,
|
||||
blocks: [{ kind: "text", text }],
|
||||
prompt: text,
|
||||
trigger: "messaging",
|
||||
clientNonce: `messaging:${event.provider}:${event.handle}`,
|
||||
});
|
||||
if (sent.runId) {
|
||||
// Typing bubbles only make sense once a reply is actually coming. Fire
|
||||
// before the enqueue so they land ahead of a fast reply, and never await:
|
||||
// a stalled vendor typing call must not hold the webhook open. The bubbles
|
||||
// clear on their own after a short display window or when the reply
|
||||
// arrives, so long runs simply outlive them.
|
||||
void deps.typing?.(event.threadId).catch((error) => {
|
||||
console.error("messaging typing indicator error", error);
|
||||
});
|
||||
await deps.jobs.enqueue(runContinueJob(sent.runId)).catch((error) => {
|
||||
console.error("messaging inbound run enqueue error", error);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true when the message was exactly a link code that redeemed:
|
||||
* the address is now bound to the issuer's chosen bot and a confirmation
|
||||
* DM is on its way. An invalid or expired code falls through silently —
|
||||
* for linked senders it reads as a normal message, for strangers nothing
|
||||
* happens (no oracle, no reply spam).
|
||||
*/
|
||||
async function tryRedeemLinkCode(
|
||||
deps: MessagingInboundDeps,
|
||||
event: MessagingInboundMessage,
|
||||
): Promise<boolean> {
|
||||
const code = normalizeMessagingLinkCode(event.content);
|
||||
if (!code) return false;
|
||||
const redeemed = await redeemMessagingLinkCode(deps.prisma, {
|
||||
code,
|
||||
provider: event.provider,
|
||||
address: event.from,
|
||||
dmThreadId: event.threadId,
|
||||
});
|
||||
if (!redeemed) return false;
|
||||
const bot = await deps.prisma.bot.findUnique({
|
||||
where: { id: redeemed.botId },
|
||||
select: { name: true },
|
||||
});
|
||||
await enqueueConfirmation(
|
||||
deps,
|
||||
{ id: redeemed.identityId },
|
||||
redeemed.confirmationKey,
|
||||
`Linked — messages here now reach "${bot?.name ?? "your agent"}".`,
|
||||
);
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Returns true when the command matched a pending item and was handled. */
|
||||
async function applyOwnerCommand(
|
||||
deps: MessagingInboundDeps,
|
||||
identity: IdentityRow,
|
||||
command: "approve" | "decline" | "leave",
|
||||
): Promise<boolean> {
|
||||
if (command === "leave") {
|
||||
const membership = await deps.prisma.messagingChannelMember.findFirst({
|
||||
where: { identityId: identity.id, status: "approved" },
|
||||
orderBy: { updatedAt: "desc" },
|
||||
});
|
||||
if (!membership) return false;
|
||||
const { count } = await deps.prisma.messagingChannelMember.updateMany({
|
||||
where: { id: membership.id, status: "approved" },
|
||||
data: { status: "left" },
|
||||
});
|
||||
// State changed under us (e.g. swept out and re-invited): treat the
|
||||
// text as a normal message rather than overwriting the newer state.
|
||||
if (count === 0) return false;
|
||||
await enqueueConfirmation(
|
||||
deps,
|
||||
identity,
|
||||
`command:leave:${membership.id}`,
|
||||
"You've left the channel. Your agent will no longer post there. The group chat itself is unchanged.",
|
||||
);
|
||||
return true;
|
||||
}
|
||||
|
||||
const membership = await deps.prisma.messagingChannelMember.findFirst({
|
||||
where: { identityId: identity.id, status: "invited" },
|
||||
orderBy: { updatedAt: "desc" },
|
||||
});
|
||||
const connection = await deps.prisma.agentConnection.findFirst({
|
||||
where: { targetBotId: identity.botId, status: "pending" },
|
||||
orderBy: { updatedAt: "desc" },
|
||||
});
|
||||
// YES/NO answers whichever pending item is newest, channel invite or
|
||||
// agent connection.
|
||||
const target =
|
||||
membership && (!connection || membership.updatedAt >= connection.updatedAt)
|
||||
? ({ kind: "channel", membership } as const)
|
||||
: connection
|
||||
? ({ kind: "connection", connection } as const)
|
||||
: null;
|
||||
if (!target) return false;
|
||||
const approved = command === "approve";
|
||||
|
||||
if (target.kind === "channel") {
|
||||
const key = `command:${command}:${target.membership.id}`;
|
||||
const claimed = await deps.prisma.$transaction(async (tx) => {
|
||||
// The claim holds the membership row lock through commit, so the
|
||||
// participant sweep can never interleave with the confirmation write.
|
||||
const { count } = await tx.messagingChannelMember.updateMany({
|
||||
where: { id: target.membership.id, status: "invited" },
|
||||
data: { status: approved ? "approved" : "declined" },
|
||||
});
|
||||
// Swept out or answered elsewhere since the read: not ours to write.
|
||||
if (count === 0) return false;
|
||||
await writeConfirmation(
|
||||
tx,
|
||||
identity,
|
||||
key,
|
||||
approved
|
||||
? "You're in. Your agent will now see and reply to that group."
|
||||
: "No problem, your agent will stay out of that group.",
|
||||
);
|
||||
return true;
|
||||
});
|
||||
if (!claimed) return false;
|
||||
await enqueueDeliverJob(deps);
|
||||
return true;
|
||||
}
|
||||
|
||||
const connectedKey = `command:connected:${target.connection.id}`;
|
||||
const requesterIdentity = approved
|
||||
? await deps.prisma.messagingIdentity.findUnique({
|
||||
where: { botId: target.connection.requesterBotId },
|
||||
})
|
||||
: null;
|
||||
const key = `command:${command}:${target.connection.id}`;
|
||||
const claimed = await deps.prisma.$transaction(async (tx) => {
|
||||
// The claim holds the connection row lock through commit, so a revoke
|
||||
// can never interleave with the confirmation writes.
|
||||
const { count } = await tx.agentConnection.updateMany({
|
||||
where: { id: target.connection.id, status: "pending" },
|
||||
data: { status: approved ? "approved" : "declined" },
|
||||
});
|
||||
// Revoked or answered elsewhere since the read: not ours to write.
|
||||
if (count === 0) return false;
|
||||
await writeConfirmation(
|
||||
tx,
|
||||
identity,
|
||||
key,
|
||||
approved
|
||||
? "Connection approved. Your agents can now message each other."
|
||||
: "Connection declined.",
|
||||
);
|
||||
if (requesterIdentity) {
|
||||
await writeConfirmation(
|
||||
tx,
|
||||
requesterIdentity,
|
||||
connectedKey,
|
||||
"Your connection request was accepted. Your agents can now message each other.",
|
||||
);
|
||||
}
|
||||
return true;
|
||||
});
|
||||
if (!claimed) return false;
|
||||
await enqueueDeliverJob(deps);
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Delete-then-insert inside the caller's claim transaction: the prior
|
||||
* cycle's row must not suppress the new confirmation. */
|
||||
async function writeConfirmation(
|
||||
tx: Pick<Prisma.TransactionClient, "messagingOutbound">,
|
||||
identity: { id: string },
|
||||
key: string,
|
||||
body: string,
|
||||
): Promise<void> {
|
||||
await tx.messagingOutbound.deleteMany({ where: { idempotencyKey: key } });
|
||||
await tx.messagingOutbound.createMany({
|
||||
data: [{ idempotencyKey: key, kind: "dm", identityId: identity.id, body }],
|
||||
skipDuplicates: true,
|
||||
});
|
||||
}
|
||||
|
||||
async function enqueueDeliverJob(deps: MessagingInboundDeps): Promise<void> {
|
||||
await deps.jobs.enqueue(messagingDeliverJob()).catch((error) => {
|
||||
console.error("messaging confirmation enqueue error", error);
|
||||
});
|
||||
}
|
||||
|
||||
async function enqueueConfirmation(
|
||||
deps: MessagingInboundDeps,
|
||||
identity: { id: string },
|
||||
key: string,
|
||||
body: string,
|
||||
): Promise<void> {
|
||||
// Keys are stable per membership/connection across approval cycles; clear
|
||||
// the prior cycle's row or skipDuplicates would swallow the new text.
|
||||
await deps.prisma.messagingOutbound.deleteMany({ where: { idempotencyKey: key } });
|
||||
await deps.prisma.messagingOutbound.createMany({
|
||||
data: [{ idempotencyKey: key, kind: "dm", identityId: identity.id, body }],
|
||||
skipDuplicates: true,
|
||||
});
|
||||
await deps.jobs.enqueue(messagingDeliverJob()).catch((error) => {
|
||||
console.error("messaging confirmation enqueue error", error);
|
||||
});
|
||||
}
|
||||
|
||||
async function handleChannelEvent(
|
||||
deps: MessagingInboundDeps,
|
||||
event: MessagingInboundMessage,
|
||||
): Promise<void> {
|
||||
const channelName = event.channelName ? sanitizeMessagingLabel(event.channelName) : null;
|
||||
const channel = await deps.prisma.messagingChannel.upsert({
|
||||
where: { threadId: event.threadId },
|
||||
create: { provider: event.provider, threadId: event.threadId, name: channelName },
|
||||
update: channelName ? { name: channelName } : {},
|
||||
});
|
||||
|
||||
const participants = [...event.participants];
|
||||
if (!participants.includes(event.from)) participants.push(event.from);
|
||||
|
||||
let hasUnlinked = false;
|
||||
for (const address of participants) {
|
||||
const identity = await deps.prisma.messagingIdentity.findUnique({
|
||||
where: { provider_address: { provider: event.provider, address } },
|
||||
});
|
||||
const member = await deps.prisma.messagingChannelMember.findUnique({
|
||||
where: { channelId_address: { channelId: channel.id, address } },
|
||||
});
|
||||
if (member) {
|
||||
// Compare against the current identity, not just null: unlinking
|
||||
// deletes the identity row but leaves this FK-free column pointing at
|
||||
// the dead id, so a re-link would otherwise never reattach and the
|
||||
// member would sit in the channel unreachable by every lookup.
|
||||
if (identity && member.identityId !== identity.id) {
|
||||
await deps.prisma.messagingChannelMember.update({
|
||||
where: { id: member.id },
|
||||
data: { identityId: identity.id },
|
||||
});
|
||||
if (member.status === "invited") await inviteMember(deps, channel, identity);
|
||||
}
|
||||
if (member.status === "left") {
|
||||
// Back in the group: restart the approval cycle.
|
||||
await deps.prisma.messagingChannelMember.update({
|
||||
where: { id: member.id },
|
||||
data: { status: "invited" },
|
||||
});
|
||||
if (identity) await inviteMember(deps, channel, identity);
|
||||
}
|
||||
if (!identity) hasUnlinked = true;
|
||||
continue;
|
||||
}
|
||||
// Upsert, not create: concurrent group webhooks race on the unique key.
|
||||
await deps.prisma.messagingChannelMember.upsert({
|
||||
where: { channelId_address: { channelId: channel.id, address } },
|
||||
create: {
|
||||
channelId: channel.id,
|
||||
address,
|
||||
identityId: identity?.id ?? null,
|
||||
status: "invited",
|
||||
},
|
||||
update: {},
|
||||
});
|
||||
if (identity) await inviteMember(deps, channel, identity);
|
||||
else hasUnlinked = true;
|
||||
}
|
||||
|
||||
// Someone removed from the group must stop receiving its content.
|
||||
// A webhook without a participants roster says nothing about membership —
|
||||
// never sweep on partial data.
|
||||
if (event.participants.length > 0) {
|
||||
await deps.prisma.messagingChannelMember.updateMany({
|
||||
where: {
|
||||
channelId: channel.id,
|
||||
address: { notIn: participants },
|
||||
status: { in: ["invited", "approved"] },
|
||||
},
|
||||
data: { status: "left" },
|
||||
});
|
||||
}
|
||||
|
||||
if (hasUnlinked && !channel.introPostedAt) {
|
||||
await deps.prisma.messagingOutbound.createMany({
|
||||
data: [
|
||||
{
|
||||
idempotencyKey: `intro:${channel.id}`,
|
||||
kind: "intro",
|
||||
threadId: channel.threadId,
|
||||
body: "Hi. This line hosts BangSo Bot personal agents. Some people in this group haven't messaged it yet; send any message to this line first if you want your own agent here.",
|
||||
},
|
||||
],
|
||||
skipDuplicates: true,
|
||||
});
|
||||
await deps.prisma.messagingChannel.update({
|
||||
where: { id: channel.id },
|
||||
data: { introPostedAt: new Date() },
|
||||
});
|
||||
await deps.jobs.enqueue(messagingDeliverJob()).catch((error) => {
|
||||
console.error("messaging intro enqueue error", error);
|
||||
});
|
||||
}
|
||||
|
||||
// Only approved owners' bots participate.
|
||||
const senderMember = await deps.prisma.messagingChannelMember.findUnique({
|
||||
where: { channelId_address: { channelId: channel.id, address: event.from } },
|
||||
});
|
||||
if (senderMember?.status !== "approved") return;
|
||||
|
||||
const senderIdentity = senderMember.identityId
|
||||
? await deps.prisma.messagingIdentity.findUnique({ where: { id: senderMember.identityId } })
|
||||
: null;
|
||||
const fromLabel = senderIdentity
|
||||
? await ownerFirstName(deps.prisma, senderIdentity.userId, event.from)
|
||||
: event.from;
|
||||
|
||||
const approved = await deps.prisma.messagingChannelMember.findMany({
|
||||
where: { channelId: channel.id, status: "approved", identityId: { not: null } },
|
||||
});
|
||||
const block: MessageBlock = {
|
||||
kind: "channel_message",
|
||||
provider: event.provider,
|
||||
channelId: channel.id,
|
||||
fromAddress: event.from,
|
||||
fromLabel,
|
||||
text: event.content,
|
||||
hop: 0,
|
||||
};
|
||||
const prompt = `[Group "${channel.name ?? "group"}", ${fromLabel}]: ${event.content}`;
|
||||
for (const member of approved) {
|
||||
const identity = await deps.prisma.messagingIdentity.findUnique({
|
||||
where: { id: member.identityId! },
|
||||
});
|
||||
if (!identity) continue;
|
||||
const thread = await deps.prisma.thread.findFirst({ where: { botId: identity.botId } });
|
||||
if (!thread) continue;
|
||||
const sent = await deps.events.sendUserMessage({
|
||||
spaceId: identity.spaceId,
|
||||
threadId: thread.id,
|
||||
botId: identity.botId,
|
||||
userId: identity.userId,
|
||||
blocks: [block],
|
||||
prompt,
|
||||
trigger: "messaging",
|
||||
clientNonce: `messaging:${event.provider}:${event.handle}`,
|
||||
});
|
||||
if (sent.runId) {
|
||||
await deps.jobs.enqueue(runContinueJob(sent.runId)).catch((error) => {
|
||||
console.error("messaging channel fan-out enqueue error", error);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function inviteMember(
|
||||
deps: MessagingInboundDeps,
|
||||
channel: { id: string; name: string | null },
|
||||
identity: IdentityRow,
|
||||
): Promise<void> {
|
||||
const name = channel.name ?? "a group chat";
|
||||
// A returning member restarts the approval cycle; clear the prior invite
|
||||
// row or skipDuplicates would leave them with no prompt to answer.
|
||||
await deps.prisma.messagingOutbound.deleteMany({
|
||||
where: { idempotencyKey: `invite:${channel.id}:${identity.id}` },
|
||||
});
|
||||
await deps.prisma.messagingOutbound.createMany({
|
||||
data: [
|
||||
{
|
||||
idempotencyKey: `invite:${channel.id}:${identity.id}`,
|
||||
kind: "dm",
|
||||
identityId: identity.id,
|
||||
body: `"${name}" was linked to your BangSo Bot agent. Reply YES to let your agent join the conversation there, or NO to stay out.`,
|
||||
},
|
||||
],
|
||||
skipDuplicates: true,
|
||||
});
|
||||
const thread = await deps.prisma.thread.findFirst({ where: { botId: identity.botId } });
|
||||
if (thread) {
|
||||
const note = await createThreadMessage(deps.prisma, {
|
||||
threadId: thread.id,
|
||||
role: "system",
|
||||
blocks: [
|
||||
{
|
||||
kind: "meta",
|
||||
text: `You were added to the group chat "${name}". Reply YES in this conversation to join it with your agent.`,
|
||||
},
|
||||
],
|
||||
});
|
||||
await deps.events.notify(thread.id, note.seq).catch(() => undefined);
|
||||
}
|
||||
await deps.jobs.enqueue(messagingDeliverJob()).catch((error) => {
|
||||
console.error("messaging invite enqueue error", error);
|
||||
});
|
||||
}
|
||||
|
||||
async function ownerFirstName(
|
||||
prisma: PrismaClient,
|
||||
userId: string,
|
||||
fallback: string,
|
||||
): Promise<string> {
|
||||
const user = await prisma.user.findUnique({ where: { id: userId }, select: { name: true } });
|
||||
const first = user?.name.trim().split(/\s+/)[0];
|
||||
return first ? sanitizeMessagingLabel(first) : fallback;
|
||||
}
|
||||
|
|
@ -0,0 +1,93 @@
|
|||
import type { MessagingSurface } from "@rakazo/adapter-kit";
|
||||
import { Hono } from "hono";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { MESSAGING_WEBHOOK_BASE_PATH, mountMessagingWebhookRoutes } from "./messaging-webhook.js";
|
||||
|
||||
/**
|
||||
* The route is a thin pass-through: verification, parsing, and status
|
||||
* handling all live inside the surface's platform adapters (covered in
|
||||
* packages/adapters). These tests pin the routing contract only.
|
||||
*/
|
||||
function mount(respond?: (provider: string, request: Request) => Response) {
|
||||
const handleWebhook = vi.fn((provider: string, request: Request) => {
|
||||
if (provider !== "sendblue" && provider !== "whatsapp") return null;
|
||||
return Promise.resolve(respond?.(provider, request) ?? Response.json({ ok: true }));
|
||||
});
|
||||
const app = new Hono();
|
||||
mountMessagingWebhookRoutes(app, {
|
||||
messaging: { handleWebhook } as unknown as MessagingSurface,
|
||||
});
|
||||
return { app, handleWebhook };
|
||||
}
|
||||
|
||||
function post(body: string) {
|
||||
return {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body,
|
||||
};
|
||||
}
|
||||
|
||||
const payload = JSON.stringify({ content: "hi", is_outbound: false });
|
||||
|
||||
describe("messaging webhook HTTP routes", () => {
|
||||
it("forwards the raw request to the surface under the :provider param", async () => {
|
||||
const { app, handleWebhook } = mount();
|
||||
const res = await app.request(`${MESSAGING_WEBHOOK_BASE_PATH}/sendblue`, post(payload));
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(handleWebhook).toHaveBeenCalledTimes(1);
|
||||
const [provider, request] = handleWebhook.mock.calls[0]! as [string, Request];
|
||||
expect(provider).toBe("sendblue");
|
||||
// The raw Request passes through untouched so platform adapters can
|
||||
// verify signatures against the exact body bytes and headers.
|
||||
expect(request.method).toBe("POST");
|
||||
expect(new URL(request.url).pathname).toBe(`${MESSAGING_WEBHOOK_BASE_PATH}/sendblue`);
|
||||
await expect(request.text()).resolves.toBe(payload);
|
||||
});
|
||||
|
||||
it("returns 404 for a provider the surface does not host", async () => {
|
||||
const { app, handleWebhook } = mount();
|
||||
const res = await app.request(`${MESSAGING_WEBHOOK_BASE_PATH}/carrier-pigeon`, post(payload));
|
||||
|
||||
expect(res.status).toBe(404);
|
||||
await expect(res.json()).resolves.toEqual({ error: "Unknown provider" });
|
||||
expect(handleWebhook).toHaveBeenCalledWith("carrier-pigeon", expect.any(Request));
|
||||
});
|
||||
|
||||
it("keeps the legacy phone webhook path routing to sendblue", async () => {
|
||||
const { app, handleWebhook } = mount();
|
||||
const res = await app.request("/api/v1/phone/webhook", post(payload));
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(handleWebhook).toHaveBeenCalledWith("sendblue", expect.any(Request));
|
||||
const [, request] = handleWebhook.mock.calls[0]! as [string, Request];
|
||||
await expect(request.text()).resolves.toBe(payload);
|
||||
});
|
||||
|
||||
it("returns the surface's response verbatim, including rejections", async () => {
|
||||
const { app } = mount(() => new Response("signature mismatch", { status: 401 }));
|
||||
const res = await app.request(`${MESSAGING_WEBHOOK_BASE_PATH}/sendblue`, post(payload));
|
||||
|
||||
expect(res.status).toBe(401);
|
||||
await expect(res.text()).resolves.toBe("signature mismatch");
|
||||
});
|
||||
|
||||
it("passes GET requests through for provider challenges", async () => {
|
||||
// WhatsApp verifies its webhook with a GET hub.challenge handshake.
|
||||
const { app, handleWebhook } = mount((_provider, request) => {
|
||||
const challenge = new URL(request.url).searchParams.get("hub.challenge");
|
||||
return new Response(challenge ?? "", { status: 200 });
|
||||
});
|
||||
const res = await app.request(
|
||||
`${MESSAGING_WEBHOOK_BASE_PATH}/whatsapp?hub.mode=subscribe&hub.challenge=12345`,
|
||||
{ method: "GET" },
|
||||
);
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
await expect(res.text()).resolves.toBe("12345");
|
||||
const [provider, request] = handleWebhook.mock.calls[0]! as [string, Request];
|
||||
expect(provider).toBe("whatsapp");
|
||||
expect(request.method).toBe("GET");
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,26 @@
|
|||
import type { MessagingSurface } from "@rakazo/adapter-kit";
|
||||
import type { Hono } from "hono";
|
||||
|
||||
export const MESSAGING_WEBHOOK_BASE_PATH = "/api/v1/messaging/webhook";
|
||||
|
||||
/**
|
||||
* Per-provider inbound webhooks. Verification (Slack HMAC, WhatsApp
|
||||
* signatures + GET challenge, Telegram secret header, sendblue shared
|
||||
* secret) happens inside the surface's platform adapters; replay safety
|
||||
* comes from per-message client nonces downstream. Mounted only when the
|
||||
* messaging surface is enabled.
|
||||
*/
|
||||
export function mountMessagingWebhookRoutes(app: Hono, deps: { messaging: MessagingSurface }) {
|
||||
app.all(`${MESSAGING_WEBHOOK_BASE_PATH}/:provider`, async (c) => {
|
||||
const response = deps.messaging.handleWebhook(c.req.param("provider"), c.req.raw);
|
||||
if (!response) return c.json({ error: "Unknown provider" }, 404);
|
||||
return response;
|
||||
});
|
||||
// The pre-multi-platform sendblue path; already-configured dashboards
|
||||
// keep delivering without an update.
|
||||
app.post("/api/v1/phone/webhook", async (c) => {
|
||||
const response = deps.messaging.handleWebhook("sendblue", c.req.raw);
|
||||
if (!response) return c.json({ error: "Unknown provider" }, 404);
|
||||
return response;
|
||||
});
|
||||
}
|
||||
|
|
@ -0,0 +1,263 @@
|
|||
import type { ComposioProvider } from "@rakazo/adapters";
|
||||
import type { Actor, MessageBlock } from "@rakazo/contracts";
|
||||
import { featuredConnectorProvidersMatch } from "@rakazo/core";
|
||||
import {
|
||||
createThreadMessage,
|
||||
IsolationError,
|
||||
type PrismaClient,
|
||||
type ThreadEvents,
|
||||
} from "@rakazo/db";
|
||||
|
||||
/**
|
||||
* First-run conversational onboarding, seeded deterministically into the bot's
|
||||
* thread: greeting, a focus choice, and Composio app cards the user authorizes
|
||||
* inline. Focus must not rename the bot. No model tokens are spent.
|
||||
*/
|
||||
|
||||
type OnboardingDeps = {
|
||||
prisma: PrismaClient;
|
||||
events: ThreadEvents;
|
||||
composio?: Pick<ComposioProvider, "catalog">;
|
||||
};
|
||||
|
||||
type FocusOption = {
|
||||
id: string;
|
||||
letter: string;
|
||||
label: string;
|
||||
summary: string;
|
||||
apps: string[];
|
||||
};
|
||||
|
||||
const FOCUS_OPTIONS: FocusOption[] = [
|
||||
{
|
||||
id: "day",
|
||||
letter: "A",
|
||||
label: "Day-to-day work",
|
||||
summary: "Slack, calendar, and email",
|
||||
apps: ["slack", "gmail", "googlecalendar"],
|
||||
},
|
||||
{
|
||||
id: "inbox",
|
||||
letter: "B",
|
||||
label: "Inbox & email",
|
||||
summary: "email and calendar",
|
||||
apps: ["gmail", "googlecalendar", "slack"],
|
||||
},
|
||||
{
|
||||
id: "research",
|
||||
letter: "C",
|
||||
label: "Research & writing",
|
||||
summary: "the web, notes, and docs",
|
||||
apps: ["hackernews", "notion", "googledocs"],
|
||||
},
|
||||
{
|
||||
id: "everything",
|
||||
letter: "D",
|
||||
label: "A bit of everything",
|
||||
summary: "Slack, calendar, and email",
|
||||
apps: ["slack", "gmail", "googlecalendar"],
|
||||
},
|
||||
];
|
||||
|
||||
const APP_DESCRIPTIONS: Record<string, string> = {
|
||||
slack: "Search, read, and send messages.",
|
||||
gmail: "Search, read, draft, and send email.",
|
||||
googlecalendar: "Search events and schedule meetings.",
|
||||
notion: "Search and edit pages and databases.",
|
||||
googledocs: "Draft and edit documents.",
|
||||
hackernews: "Search stories and discussions.",
|
||||
};
|
||||
|
||||
const APP_NAMES: Record<string, string> = {
|
||||
gmail: "Gmail",
|
||||
googlecalendar: "Google Calendar",
|
||||
googledocs: "Google Docs",
|
||||
hackernews: "Hacker News",
|
||||
notion: "Notion",
|
||||
slack: "Slack",
|
||||
};
|
||||
|
||||
async function requireBotThread(deps: OnboardingDeps, actor: Actor, botId: string) {
|
||||
const bot = await deps.prisma.bot.findFirst({
|
||||
where: { id: botId, spaceId: actor.spaceId, userId: actor.userId },
|
||||
include: { thread: true },
|
||||
});
|
||||
if (!bot?.thread) throw new IsolationError();
|
||||
return { bot, thread: bot.thread };
|
||||
}
|
||||
|
||||
async function post(
|
||||
deps: OnboardingDeps,
|
||||
target: { spaceId: string; botId: string; threadId: string },
|
||||
blocks: MessageBlock[],
|
||||
): Promise<string> {
|
||||
const message = await createThreadMessage(deps.prisma, {
|
||||
threadId: target.threadId,
|
||||
role: "bot",
|
||||
blocks,
|
||||
});
|
||||
await deps.events.append({
|
||||
spaceId: target.spaceId,
|
||||
threadId: target.threadId,
|
||||
botId: target.botId,
|
||||
type: "thread.message.created",
|
||||
payload: { messageId: message.id, role: "bot", blocks },
|
||||
});
|
||||
return message.id;
|
||||
}
|
||||
|
||||
async function updateBlocks(
|
||||
deps: OnboardingDeps,
|
||||
target: { spaceId: string; botId: string; threadId: string },
|
||||
messageId: string,
|
||||
blocks: MessageBlock[],
|
||||
): Promise<void> {
|
||||
await deps.prisma.message.update({ where: { id: messageId }, data: { blocks } });
|
||||
await deps.events.append({
|
||||
spaceId: target.spaceId,
|
||||
threadId: target.threadId,
|
||||
botId: target.botId,
|
||||
type: "thread.message.updated",
|
||||
payload: { messageId, role: "bot", blocks },
|
||||
});
|
||||
}
|
||||
|
||||
export async function startOnboarding(
|
||||
deps: OnboardingDeps,
|
||||
actor: Actor,
|
||||
botId: string,
|
||||
): Promise<void> {
|
||||
const { bot, thread } = await requireBotThread(deps, actor, botId);
|
||||
const existing = await deps.prisma.message.count({ where: { threadId: thread.id } });
|
||||
if (existing > 0) return;
|
||||
const user = await deps.prisma.user.findUnique({
|
||||
where: { id: actor.userId },
|
||||
select: { name: true },
|
||||
});
|
||||
const firstName = (user?.name ?? "there").split(/\s+/)[0];
|
||||
const target = { spaceId: actor.spaceId, botId: bot.id, threadId: thread.id };
|
||||
await post(deps, target, [
|
||||
{ kind: "text", text: `Hey ${firstName}. Fresh start on my side, so I’ll keep this short.` },
|
||||
]);
|
||||
await post(deps, target, [
|
||||
{
|
||||
kind: "choice",
|
||||
question: "What do you want me on first?",
|
||||
options: FOCUS_OPTIONS.map(({ id, letter, label }) => ({ id, letter, label })),
|
||||
},
|
||||
]);
|
||||
}
|
||||
|
||||
export async function chooseFocus(
|
||||
deps: OnboardingDeps,
|
||||
actor: Actor,
|
||||
botId: string,
|
||||
optionId: string,
|
||||
): Promise<void> {
|
||||
const option = FOCUS_OPTIONS.find((entry) => entry.id === optionId);
|
||||
if (!option) throw new IsolationError();
|
||||
const { bot, thread } = await requireBotThread(deps, actor, botId);
|
||||
const target = { spaceId: actor.spaceId, botId: bot.id, threadId: thread.id };
|
||||
|
||||
const recent = await deps.prisma.message.findMany({
|
||||
where: { threadId: thread.id },
|
||||
orderBy: { createdAt: "asc" },
|
||||
});
|
||||
const pending = recent.find((message) =>
|
||||
(message.blocks as MessageBlock[]).some((block) => block.kind === "choice" && !block.answerId),
|
||||
);
|
||||
if (!pending) return;
|
||||
const blocks = (pending.blocks as MessageBlock[]).map((block) =>
|
||||
block.kind === "choice" ? { ...block, answerId: option.id } : block,
|
||||
);
|
||||
await updateBlocks(deps, target, pending.id, blocks);
|
||||
|
||||
// Keep the name and title the user chose when creating the bot; the focus
|
||||
// step only suggests apps, it must not rename the bot.
|
||||
await post(deps, target, [
|
||||
{
|
||||
kind: "text",
|
||||
text: `Got it. ${capitalize(option.summary)}. I’ll see what’s already connected so I don’t make you set something up twice.`,
|
||||
},
|
||||
]);
|
||||
|
||||
const catalog = deps.composio
|
||||
? await deps.composio
|
||||
.catalog({
|
||||
operationId: "onboarding.choose",
|
||||
traceId: "onboarding.choose",
|
||||
spaceId: actor.spaceId,
|
||||
userId: actor.userId,
|
||||
botId: bot.id,
|
||||
signal: new AbortController().signal,
|
||||
})
|
||||
.catch(() => [])
|
||||
: [];
|
||||
const bySlug = new Map(catalog.map((entry) => [entry.slug.toLowerCase(), entry]));
|
||||
const cards: MessageBlock[] = option.apps.map((slug) => {
|
||||
const entry = bySlug.get(slug.toLowerCase());
|
||||
return {
|
||||
kind: "app_connect",
|
||||
provider: entry?.slug ?? slug,
|
||||
name: entry?.name ?? APP_NAMES[slug] ?? capitalize(slug),
|
||||
description: APP_DESCRIPTIONS[slug] ?? `Connect ${entry?.name ?? slug} to your account.`,
|
||||
logo: entry?.logo ?? null,
|
||||
status: entry?.connected ? "connected" : "pending",
|
||||
};
|
||||
});
|
||||
const cardNames = cards
|
||||
.map((card) => (card.kind === "app_connect" ? card.name : ""))
|
||||
.filter(Boolean);
|
||||
const named = `${cardNames.slice(0, -1).join(", ")}${cardNames.length > 1 ? ", and " : ""}${cardNames.at(-1)}`;
|
||||
await post(deps, target, [
|
||||
{
|
||||
kind: "text",
|
||||
text: `${named} are a good place to start. Connect them here and I’ll use what you already have.`,
|
||||
},
|
||||
]);
|
||||
await post(deps, target, cards);
|
||||
await post(deps, target, [
|
||||
{
|
||||
kind: "text",
|
||||
text: `Hit those ${cards.length === 1 ? "one" : cards.length === 2 ? "two" : "three"} and I’ll start pulling the picture.`,
|
||||
},
|
||||
]);
|
||||
}
|
||||
|
||||
export async function markAppConnected(
|
||||
deps: OnboardingDeps,
|
||||
actor: Actor,
|
||||
botId: string,
|
||||
provider: string,
|
||||
): Promise<void> {
|
||||
const { bot, thread } = await requireBotThread(deps, actor, botId);
|
||||
const target = { spaceId: actor.spaceId, botId: bot.id, threadId: thread.id };
|
||||
const messages = await deps.prisma.message.findMany({
|
||||
where: { threadId: thread.id },
|
||||
select: { id: true, blocks: true },
|
||||
orderBy: { createdAt: "asc" },
|
||||
take: 100,
|
||||
});
|
||||
for (const message of messages) {
|
||||
const blocks = message.blocks as MessageBlock[];
|
||||
if (
|
||||
!blocks.some(
|
||||
(block) =>
|
||||
block.kind === "app_connect" &&
|
||||
featuredConnectorProvidersMatch(block.provider, provider) &&
|
||||
block.status !== "connected",
|
||||
)
|
||||
)
|
||||
continue;
|
||||
const next = blocks.map((block) =>
|
||||
block.kind === "app_connect" && featuredConnectorProvidersMatch(block.provider, provider)
|
||||
? { ...block, status: "connected" as const }
|
||||
: block,
|
||||
);
|
||||
await updateBlocks(deps, target, message.id, next);
|
||||
}
|
||||
}
|
||||
|
||||
function capitalize(value: string): string {
|
||||
return value.length > 0 ? (value[0] ?? "").toUpperCase() + value.slice(1) : value;
|
||||
}
|
||||
|
|
@ -0,0 +1,236 @@
|
|||
import { describe, expect, it, vi } from "vitest";
|
||||
import { persistMemoryProviderConfig, updateMemoryProviderDefaultScope } from "./router.js";
|
||||
|
||||
const actor = {
|
||||
userId: "user-1",
|
||||
spaceId: "ws-1",
|
||||
email: "a@b.com",
|
||||
isDeploymentOwner: false,
|
||||
};
|
||||
|
||||
function makeDeps(
|
||||
overrides: {
|
||||
existing?: { id: string; secretId: string } | null;
|
||||
upsertResult?: {
|
||||
provider: string;
|
||||
settings: Record<string, string>;
|
||||
defaultMemoryScope: string;
|
||||
updatedAt: Date;
|
||||
};
|
||||
updateResult?: {
|
||||
provider: string;
|
||||
settings: Record<string, string>;
|
||||
defaultMemoryScope: string;
|
||||
updatedAt: Date;
|
||||
};
|
||||
spaceOwner?: boolean;
|
||||
memberRole?: string;
|
||||
} = {},
|
||||
) {
|
||||
const secretCreate = vi.fn().mockResolvedValue({ id: "secret-new" });
|
||||
const secretDeleteMany = vi.fn().mockResolvedValue({ count: 1 });
|
||||
const findUnique = vi.fn().mockResolvedValue(overrides.existing ?? null);
|
||||
const upsert = vi.fn().mockResolvedValue(
|
||||
overrides.upsertResult ?? {
|
||||
provider: "supermemory",
|
||||
settings: { mode: "cloud", baseUrl: "https://api.supermemory.ai" },
|
||||
defaultMemoryScope: "isolated",
|
||||
updatedAt: new Date("2026-08-19T00:00:00.000Z"),
|
||||
},
|
||||
);
|
||||
const update = vi.fn().mockResolvedValue(
|
||||
overrides.updateResult ?? {
|
||||
provider: "supermemory",
|
||||
settings: { mode: "cloud", baseUrl: "https://api.supermemory.ai" },
|
||||
defaultMemoryScope: "shared",
|
||||
updatedAt: new Date("2026-08-20T00:00:00.000Z"),
|
||||
},
|
||||
);
|
||||
const prisma = {
|
||||
spaceMember: {
|
||||
findUnique: vi
|
||||
.fn()
|
||||
.mockResolvedValue(
|
||||
overrides.spaceOwner === false ? null : { role: overrides.memberRole ?? "owner" },
|
||||
),
|
||||
},
|
||||
spaceMemoryConfig: { findUnique, update, upsert },
|
||||
secret: { create: secretCreate, deleteMany: secretDeleteMany },
|
||||
$transaction: vi.fn(),
|
||||
};
|
||||
prisma.$transaction.mockImplementation(async (callback: (tx: typeof prisma) => unknown) =>
|
||||
callback(prisma),
|
||||
);
|
||||
const deps = {
|
||||
prisma,
|
||||
secrets: { put: vi.fn().mockResolvedValue({ id: "secret-new", ciphertext: "cipher" }) },
|
||||
};
|
||||
return {
|
||||
deps,
|
||||
secretCreate,
|
||||
secretDeleteMany,
|
||||
findUnique,
|
||||
upsert,
|
||||
update,
|
||||
transaction: prisma.$transaction,
|
||||
};
|
||||
}
|
||||
|
||||
function connectionInput(mode: "cloud" | "local", baseUrl?: string) {
|
||||
return {
|
||||
provider: "supermemory",
|
||||
settings: { mode, ...(baseUrl ? { baseUrl } : {}) },
|
||||
credentials: { apiKey: "sm_test_key_12345" },
|
||||
defaultMemoryScope: "isolated" as const,
|
||||
};
|
||||
}
|
||||
|
||||
describe("persistMemoryProviderConfig", () => {
|
||||
it("rejects non-owners before probing or writing Space configuration", async () => {
|
||||
const fetchMock = vi.fn();
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
const { deps, upsert } = makeDeps({ spaceOwner: false });
|
||||
|
||||
await expect(
|
||||
persistMemoryProviderConfig(deps as never, actor, connectionInput("cloud")),
|
||||
).rejects.toThrow();
|
||||
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
expect(upsert).not.toHaveBeenCalled();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it("rejects local mode without a baseUrl, without touching the database", async () => {
|
||||
const { deps, upsert } = makeDeps();
|
||||
await expect(
|
||||
persistMemoryProviderConfig(deps as never, actor, connectionInput("local")),
|
||||
).rejects.toThrow(/baseUrl/);
|
||||
expect(upsert).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects a non-loopback baseUrl in local mode without probing or touching the database", async () => {
|
||||
const fetchMock = vi.fn();
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
const { deps, upsert } = makeDeps();
|
||||
await expect(
|
||||
persistMemoryProviderConfig(
|
||||
deps as never,
|
||||
actor,
|
||||
connectionInput("local", "http://169.254.169.254/latest/meta-data/"),
|
||||
),
|
||||
).rejects.toThrow(/loopback/);
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
expect(upsert).not.toHaveBeenCalled();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it("probes before persisting, and rejects (without writing) when the probe fails", async () => {
|
||||
vi.stubGlobal("fetch", vi.fn().mockResolvedValue(new Response("", { status: 401 })));
|
||||
const { deps, upsert } = makeDeps();
|
||||
await expect(
|
||||
persistMemoryProviderConfig(deps as never, actor, {
|
||||
...connectionInput("local", "http://localhost:6767"),
|
||||
credentials: { apiKey: "sm_bad_key" },
|
||||
}),
|
||||
).rejects.toThrow();
|
||||
expect(upsert).not.toHaveBeenCalled();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it("accepts a bracketed IPv6 loopback base URL in local mode", async () => {
|
||||
const fetchMock = vi.fn().mockResolvedValue(new Response("[]", { status: 200 }));
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
const { deps, upsert } = makeDeps();
|
||||
|
||||
await persistMemoryProviderConfig(
|
||||
deps as never,
|
||||
actor,
|
||||
connectionInput("local", "http://[::1]:6767"),
|
||||
);
|
||||
|
||||
expect(fetchMock.mock.calls[0]![0]).toBe("http://[::1]:6767/v3/container-tags/list");
|
||||
expect(upsert).toHaveBeenCalled();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it("connects cloud mode, defaulting the base URL, and returns the serialized config", async () => {
|
||||
vi.stubGlobal("fetch", vi.fn().mockResolvedValue(new Response("[]", { status: 200 })));
|
||||
const { deps, upsert, transaction } = makeDeps();
|
||||
const result = await persistMemoryProviderConfig(
|
||||
deps as never,
|
||||
actor,
|
||||
connectionInput("cloud"),
|
||||
);
|
||||
expect(upsert).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
where: { spaceId: "ws-1" },
|
||||
create: expect.objectContaining({
|
||||
provider: "supermemory",
|
||||
settings: { mode: "cloud", baseUrl: "https://api.supermemory.ai" },
|
||||
}),
|
||||
}),
|
||||
);
|
||||
expect(result).toEqual({
|
||||
provider: "supermemory",
|
||||
settings: { mode: "cloud", baseUrl: "https://api.supermemory.ai" },
|
||||
defaultMemoryScope: "isolated",
|
||||
updatedAt: "2026-08-19T00:00:00.000Z",
|
||||
});
|
||||
expect(transaction).toHaveBeenCalledOnce();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it("deletes the old secret when replacing an existing config with a new key", async () => {
|
||||
vi.stubGlobal("fetch", vi.fn().mockResolvedValue(new Response("[]", { status: 200 })));
|
||||
const { deps, secretDeleteMany } = makeDeps({
|
||||
existing: { id: "cfg-1", secretId: "secret-old" },
|
||||
});
|
||||
await persistMemoryProviderConfig(deps as never, actor, {
|
||||
...connectionInput("cloud"),
|
||||
credentials: { apiKey: "sm_new_key_12345" },
|
||||
});
|
||||
expect(secretDeleteMany).toHaveBeenCalledWith({ where: { id: "secret-old" } });
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
});
|
||||
|
||||
describe("updateMemoryProviderDefaultScope", () => {
|
||||
it("accepts owners with additional Better Auth roles", async () => {
|
||||
const { deps, update } = makeDeps({
|
||||
existing: { id: "cfg-1", secretId: "secret-existing" },
|
||||
memberRole: "owner,admin",
|
||||
});
|
||||
|
||||
await updateMemoryProviderDefaultScope(deps as never, actor, "shared");
|
||||
|
||||
expect(update).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("updates only the generic scope setting and retains the provider secret", async () => {
|
||||
const { deps, update, secretCreate, secretDeleteMany } = makeDeps({
|
||||
existing: { id: "cfg-1", secretId: "secret-existing" },
|
||||
});
|
||||
|
||||
const result = await updateMemoryProviderDefaultScope(deps as never, actor, "shared");
|
||||
|
||||
expect(update).toHaveBeenCalledWith({
|
||||
where: { id: "cfg-1" },
|
||||
data: { defaultMemoryScope: "shared" },
|
||||
});
|
||||
expect(secretCreate).not.toHaveBeenCalled();
|
||||
expect(secretDeleteMany).not.toHaveBeenCalled();
|
||||
expect(result.defaultMemoryScope).toBe("shared");
|
||||
});
|
||||
|
||||
it("rejects non-owners without updating provider configuration", async () => {
|
||||
const { deps, update } = makeDeps({
|
||||
existing: { id: "cfg-1", secretId: "secret-existing" },
|
||||
spaceOwner: false,
|
||||
});
|
||||
|
||||
await expect(
|
||||
updateMemoryProviderDefaultScope(deps as never, actor, "shared"),
|
||||
).rejects.toThrow();
|
||||
expect(update).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,924 @@
|
|||
import { RPCHandler } from "@orpc/server/fetch";
|
||||
import type { Actor } from "@rakazo/contracts";
|
||||
import type { PrismaClient } from "@rakazo/db";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { createRouter, type RouterDeps } from "./router.js";
|
||||
|
||||
/** Prisma filters the handlers use when scoping memberships to the caller. */
|
||||
type MembershipWhere = { id?: string; identityId?: { in: string[] } };
|
||||
|
||||
const identity = {
|
||||
id: "mi-1",
|
||||
provider: "sendblue",
|
||||
address: "+15551111111",
|
||||
userId: "user-1",
|
||||
spaceId: "ws-1",
|
||||
botId: "bot-1",
|
||||
outboundSinceInbound: 0,
|
||||
};
|
||||
|
||||
function messagingDeps(
|
||||
overrides: {
|
||||
enabled?: boolean;
|
||||
identity?: unknown;
|
||||
/** Every chat app the caller has linked; defaults to the single identity. */
|
||||
identities?: Array<Record<string, unknown>>;
|
||||
membership?: Record<string, unknown> | null;
|
||||
memberships?: Array<Record<string, unknown>>;
|
||||
connection?: Record<string, unknown> | null;
|
||||
connections?: Array<Record<string, unknown>>;
|
||||
} = {},
|
||||
) {
|
||||
const resolvedIdentity = overrides.identity === undefined ? identity : overrides.identity;
|
||||
const membership =
|
||||
overrides.membership === undefined
|
||||
? {
|
||||
id: "cm-1",
|
||||
channelId: "ch-1",
|
||||
address: "+15551111111",
|
||||
identityId: "mi-1",
|
||||
status: "invited",
|
||||
channel: {
|
||||
id: "ch-1",
|
||||
provider: "sendblue",
|
||||
name: "Family",
|
||||
members: [{ id: "cm-1" }, { id: "cm-2" }],
|
||||
},
|
||||
}
|
||||
: overrides.membership;
|
||||
const connection =
|
||||
overrides.connection === undefined
|
||||
? {
|
||||
id: "ac-1",
|
||||
requesterBotId: "bot-9",
|
||||
targetBotId: "bot-1",
|
||||
status: "pending",
|
||||
}
|
||||
: overrides.connection;
|
||||
// One user can hold several memberships in the same channel (one per linked
|
||||
// chat app), so the mock resolves them by id the way the handlers do.
|
||||
const membershipPool = (overrides.memberships ?? (membership ? [membership] : [])).map(
|
||||
(row) => ({ ...row }) as Record<string, unknown>,
|
||||
);
|
||||
const ownedBy = (where: { identityId?: { in: string[] } } | undefined, row: unknown) =>
|
||||
!where?.identityId ||
|
||||
where.identityId.in.includes(String((row as { identityId?: string })?.identityId));
|
||||
const visibleMemberships = (where: { identityId?: { in: string[] } } | undefined) =>
|
||||
membershipPool.filter((row) => ownedBy(where, row));
|
||||
const findMembership = (where: { id?: string; identityId?: { in: string[] } } | undefined) => {
|
||||
const pool = visibleMemberships(where);
|
||||
return where?.id ? (pool.find((row) => row.id === where.id) ?? null) : (pool[0] ?? null);
|
||||
};
|
||||
const defaultChannel = { id: "ch-1", provider: "sendblue", name: "Family", members: [] };
|
||||
const connectionState = connection ? { ...connection } : null;
|
||||
const prisma = {
|
||||
messagingIdentity: {
|
||||
findFirst: vi.fn(async () => resolvedIdentity),
|
||||
findMany: vi.fn(
|
||||
async () => overrides.identities ?? (resolvedIdentity ? [resolvedIdentity] : []),
|
||||
),
|
||||
update: vi.fn(async ({ data }: { data: Record<string, unknown> }) => ({
|
||||
...(resolvedIdentity as Record<string, unknown>),
|
||||
...data,
|
||||
})),
|
||||
deleteMany: vi.fn(async ({ where }: { where: { id: string } }) => ({
|
||||
count: resolvedIdentity && where.id === "mi-1" ? 1 : 0,
|
||||
})),
|
||||
findUnique: vi.fn(async ({ where }: { where: { botId?: string } }) =>
|
||||
where.botId === "bot-9"
|
||||
? {
|
||||
id: "mi-9",
|
||||
provider: "sendblue",
|
||||
address: "+15559999999",
|
||||
userId: "user-9",
|
||||
botId: "bot-9",
|
||||
}
|
||||
: resolvedIdentity,
|
||||
),
|
||||
},
|
||||
messagingChannelMember: {
|
||||
findMany: vi.fn(async ({ where }: { where?: MembershipWhere } = {}) =>
|
||||
visibleMemberships(where),
|
||||
),
|
||||
findFirst: vi.fn(async ({ where }: { where?: MembershipWhere } = {}) =>
|
||||
findMembership(where),
|
||||
),
|
||||
update: vi.fn(async ({ where, data }: { where?: MembershipWhere; data: unknown }) => {
|
||||
const row = findMembership(where);
|
||||
if (row) Object.assign(row, data as object);
|
||||
return { ...row, channel: row?.channel ?? defaultChannel };
|
||||
}),
|
||||
updateMany: vi.fn(
|
||||
async ({
|
||||
where,
|
||||
data,
|
||||
}: {
|
||||
where: MembershipWhere & { status?: string };
|
||||
data: Record<string, unknown>;
|
||||
}) => {
|
||||
const row = findMembership(where);
|
||||
if (!row) return { count: 0 };
|
||||
if (where.status && row.status !== where.status) return { count: 0 };
|
||||
Object.assign(row, data);
|
||||
return { count: 1 };
|
||||
},
|
||||
),
|
||||
findUniqueOrThrow: vi.fn(async ({ where }: { where?: MembershipWhere } = {}) =>
|
||||
findMembership(where),
|
||||
),
|
||||
},
|
||||
agentConnection: {
|
||||
findMany: vi.fn(async () => overrides.connections ?? (connection ? [connection] : [])),
|
||||
findFirst: vi.fn(
|
||||
async ({
|
||||
where,
|
||||
}: {
|
||||
where?: {
|
||||
id?: string;
|
||||
targetBotId?: string | { in: string[] };
|
||||
status?: string;
|
||||
OR?: Array<Record<string, string | { in: string[] }>>;
|
||||
};
|
||||
}) => {
|
||||
// Handlers scope by every linked identity, so bot filters arrive
|
||||
// as { in: [...] } lists.
|
||||
const matches = (filter: string | { in: string[] } | undefined, value: string) =>
|
||||
filter === undefined ||
|
||||
(typeof filter === "string" ? filter === value : filter.in.includes(value));
|
||||
if (!connection) return null;
|
||||
if (where?.id && connection.id !== where.id) return null;
|
||||
if (!matches(where?.targetBotId, connection.targetBotId)) return null;
|
||||
if (where?.status && connection.status !== where.status) return null;
|
||||
if (where?.OR) {
|
||||
const involved = where.OR.some(
|
||||
(cond) =>
|
||||
matches(cond.requesterBotId, connection.requesterBotId) &&
|
||||
matches(cond.targetBotId, connection.targetBotId) &&
|
||||
(cond.requesterBotId !== undefined || cond.targetBotId !== undefined),
|
||||
);
|
||||
if (!involved) return null;
|
||||
}
|
||||
return connection;
|
||||
},
|
||||
),
|
||||
update: vi.fn(async ({ data }: { data: unknown }) => ({
|
||||
...connection,
|
||||
...(data as object),
|
||||
})),
|
||||
updateMany: vi.fn(
|
||||
async ({
|
||||
where,
|
||||
data,
|
||||
}: {
|
||||
where: { id?: string; status?: string };
|
||||
data: Record<string, unknown>;
|
||||
}) => {
|
||||
if (!connectionState) return { count: 0 };
|
||||
if (where.id && connectionState.id !== where.id) return { count: 0 };
|
||||
if (where.status && connectionState.status !== where.status) return { count: 0 };
|
||||
Object.assign(connectionState, data);
|
||||
return { count: 1 };
|
||||
},
|
||||
),
|
||||
findUniqueOrThrow: vi.fn(async () => connectionState),
|
||||
},
|
||||
bot: {
|
||||
findUnique: vi.fn(async ({ where }: { where: { id: string } }) => ({
|
||||
id: where.id,
|
||||
name: where.id === "bot-9" ? "Helper" : "Assistant",
|
||||
})),
|
||||
findFirst: vi.fn(async ({ where }: { where: { id: string } }) =>
|
||||
where.id.startsWith("bot-") ? { id: where.id } : null,
|
||||
),
|
||||
},
|
||||
messagingLinkCode: {
|
||||
deleteMany: vi.fn(async () => ({ count: 0 })),
|
||||
create: vi.fn(async ({ data }: { data: Record<string, unknown> }) => data),
|
||||
},
|
||||
user: {
|
||||
findUnique: vi.fn(async () => ({ id: "user-9", name: "Bob Owner" })),
|
||||
},
|
||||
} as unknown as PrismaClient;
|
||||
const outboundRows: Array<Record<string, unknown>> = [];
|
||||
const messagingOutbound = {
|
||||
createMany: vi.fn(async ({ data }: { data: Array<Record<string, unknown>> }) => {
|
||||
let count = 0;
|
||||
for (const item of data) {
|
||||
// Honors skipDuplicates against the idempotencyKey unique key.
|
||||
if (outboundRows.some((row) => row.idempotencyKey === item.idempotencyKey)) continue;
|
||||
outboundRows.push(item);
|
||||
count += 1;
|
||||
}
|
||||
return { count };
|
||||
}),
|
||||
deleteMany: vi.fn(
|
||||
async ({
|
||||
where,
|
||||
}: {
|
||||
where: {
|
||||
idempotencyKey?: string;
|
||||
status?: string;
|
||||
OR?: Array<{ status?: string; providerHandle?: null }>;
|
||||
};
|
||||
}) => {
|
||||
let count = 0;
|
||||
for (let i = outboundRows.length - 1; i >= 0; i -= 1) {
|
||||
const row = outboundRows[i]!;
|
||||
if (where.idempotencyKey && row.idempotencyKey !== where.idempotencyKey) continue;
|
||||
if (where.status && row.status !== where.status) continue;
|
||||
if (where.OR) {
|
||||
const matches = where.OR.some((clause) => {
|
||||
if (clause.status && row.status !== clause.status) return false;
|
||||
if ("providerHandle" in clause && clause.providerHandle === null) {
|
||||
return row.providerHandle == null;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
if (!matches) continue;
|
||||
}
|
||||
outboundRows.splice(i, 1);
|
||||
count += 1;
|
||||
}
|
||||
return { count };
|
||||
},
|
||||
),
|
||||
};
|
||||
(prisma as { messagingOutbound?: unknown }).messagingOutbound = messagingOutbound;
|
||||
// The claim-and-confirm handlers run inside one interactive transaction;
|
||||
// the mock passes the same stateful models as the tx delegate.
|
||||
(prisma as { $transaction?: unknown }).$transaction = vi.fn(
|
||||
async (fn: (tx: unknown) => Promise<unknown>) => fn(prisma),
|
||||
);
|
||||
const enqueue = vi.fn(async () => undefined);
|
||||
const deps = {
|
||||
prisma,
|
||||
jobs: { enqueue },
|
||||
env: {
|
||||
defaultProvider: "fake",
|
||||
defaultModel: "fake-model",
|
||||
webOrigin: "http://127.0.0.1:5173",
|
||||
screenProxySecret: "fake-test-secret",
|
||||
sandboxProvider: "fake",
|
||||
},
|
||||
messaging: {
|
||||
enabled: overrides.enabled ?? true,
|
||||
providers: ["sendblue"],
|
||||
openSignup: false,
|
||||
},
|
||||
dataDir: "/tmp/rakazo-router-test",
|
||||
} as unknown as RouterDeps;
|
||||
const actor = {
|
||||
spaceId: "ws-1",
|
||||
userId: "user-1",
|
||||
email: "user@rakazo.test",
|
||||
isDeploymentOwner: false,
|
||||
} satisfies Actor;
|
||||
return {
|
||||
prisma,
|
||||
deps,
|
||||
actor,
|
||||
outboundRows,
|
||||
enqueue,
|
||||
handler: new RPCHandler(createRouter(deps)),
|
||||
};
|
||||
}
|
||||
|
||||
async function call(handler: RPCHandler<never>, actor: Actor, path: string, body: unknown = {}) {
|
||||
const { response } = await handler.handle(
|
||||
new Request(`http://127.0.0.1/rpc/${path}`, {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ json: body }),
|
||||
}),
|
||||
{ prefix: "/rpc", context: { actor } },
|
||||
);
|
||||
return response;
|
||||
}
|
||||
|
||||
describe("messaging.status", () => {
|
||||
it("reports enablement, providers, and the caller's linked identities", async () => {
|
||||
const { handler, actor } = messagingDeps({ enabled: true });
|
||||
const response = await call(handler, actor, "messaging/status");
|
||||
expect(response.status).toBe(200);
|
||||
await expect(response.json()).resolves.toEqual({
|
||||
json: {
|
||||
enabled: true,
|
||||
providers: ["sendblue"],
|
||||
openSignup: false,
|
||||
identities: [
|
||||
{
|
||||
id: "mi-1",
|
||||
provider: "sendblue",
|
||||
address: "+15551111111",
|
||||
botId: "bot-1",
|
||||
botName: "Assistant",
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("reports no identities when the caller has not linked a chat app", async () => {
|
||||
const { handler, actor } = messagingDeps({ identity: null });
|
||||
const response = await call(handler, actor, "messaging/status");
|
||||
await expect(response.json()).resolves.toEqual({
|
||||
json: { enabled: true, providers: ["sendblue"], openSignup: false, identities: [] },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("messaging.link", () => {
|
||||
it("issues a formatted single-use code for an owned unlinked bot", async () => {
|
||||
const { handler, actor, prisma } = messagingDeps({ identity: null });
|
||||
(prisma.messagingIdentity.findUnique as ReturnType<typeof vi.fn>).mockResolvedValue(null);
|
||||
const response = await call(handler, actor, "messaging/link/start", { botId: "bot-2" });
|
||||
expect(response.status).toBe(200);
|
||||
const body = (await response.json()) as { json: { code: string; expiresAt: string } };
|
||||
expect(body.json.code).toMatch(/^[A-Z0-9]{4}-[A-Z0-9]{4}$/);
|
||||
expect(Date.parse(body.json.expiresAt)).toBeGreaterThan(Date.now());
|
||||
expect(prisma.messagingLinkCode.create).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
data: expect.objectContaining({ userId: "user-1", botId: "bot-2" }),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("refuses to issue a code for an already-linked bot", async () => {
|
||||
const { handler, actor } = messagingDeps();
|
||||
// messagingIdentity.findUnique resolves an identity for bot-1 by default.
|
||||
const response = await call(handler, actor, "messaging/link/start", { botId: "bot-1" });
|
||||
expect(response.status).toBe(400);
|
||||
});
|
||||
});
|
||||
|
||||
describe("messaging.identities", () => {
|
||||
it("re-points an identity at another owned bot", async () => {
|
||||
const { handler, actor } = messagingDeps();
|
||||
const response = await call(handler, actor, "messaging/identities/setBot", {
|
||||
identityId: "mi-1",
|
||||
botId: "bot-2",
|
||||
});
|
||||
expect(response.status).toBe(200);
|
||||
await expect(response.json()).resolves.toEqual({
|
||||
json: {
|
||||
id: "mi-1",
|
||||
provider: "sendblue",
|
||||
address: "+15551111111",
|
||||
botId: "bot-2",
|
||||
botName: "Assistant",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("unlinks only the caller's own identity", async () => {
|
||||
const { handler, actor, prisma } = messagingDeps();
|
||||
const response = await call(handler, actor, "messaging/identities/unlink", {
|
||||
identityId: "mi-1",
|
||||
});
|
||||
expect(response.status).toBe(200);
|
||||
expect(prisma.messagingIdentity.deleteMany).toHaveBeenCalledWith({
|
||||
where: { id: "mi-1", userId: "user-1" },
|
||||
});
|
||||
const missing = await call(handler, actor, "messaging/identities/unlink", {
|
||||
identityId: "mi-other",
|
||||
});
|
||||
expect(missing.status).toBe(404);
|
||||
});
|
||||
});
|
||||
|
||||
describe("messaging.channels", () => {
|
||||
it("lists the caller's memberships with channel names, counting only active members", async () => {
|
||||
const { handler, actor, prisma } = messagingDeps();
|
||||
const response = await call(handler, actor, "messaging/channels/list");
|
||||
expect(prisma.messagingChannelMember.findMany).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
include: expect.objectContaining({
|
||||
channel: expect.objectContaining({
|
||||
include: expect.objectContaining({
|
||||
members: expect.objectContaining({
|
||||
where: { status: { in: ["invited", "approved"] } },
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
);
|
||||
await expect(response.json()).resolves.toEqual({
|
||||
json: [
|
||||
{
|
||||
id: "cm-1",
|
||||
channelId: "ch-1",
|
||||
identityId: "mi-1",
|
||||
provider: "sendblue",
|
||||
name: "Family",
|
||||
status: "invited",
|
||||
memberCount: 2,
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it("approves an invited membership", async () => {
|
||||
const { handler, actor, prisma } = messagingDeps();
|
||||
const approved = await call(handler, actor, "messaging/channels/respond", {
|
||||
membershipId: "cm-1",
|
||||
accept: true,
|
||||
});
|
||||
expect(prisma.messagingChannelMember.updateMany).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
where: { id: "cm-1", status: "invited" },
|
||||
data: { status: "approved" },
|
||||
}),
|
||||
);
|
||||
await expect(approved.json()).resolves.toEqual({
|
||||
json: expect.objectContaining({ status: "approved" }),
|
||||
});
|
||||
});
|
||||
|
||||
it("declines an invited membership on accept=false", async () => {
|
||||
const { handler, actor, prisma } = messagingDeps();
|
||||
const declined = await call(handler, actor, "messaging/channels/respond", {
|
||||
membershipId: "cm-1",
|
||||
accept: false,
|
||||
});
|
||||
expect(prisma.messagingChannelMember.updateMany).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
where: { id: "cm-1", status: "invited" },
|
||||
data: { status: "declined" },
|
||||
}),
|
||||
);
|
||||
await expect(declined.json()).resolves.toEqual({
|
||||
json: expect.objectContaining({ status: "declined" }),
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects respond on another user's membership or a non-invited one", async () => {
|
||||
const foreign = messagingDeps({ membership: null });
|
||||
const response = await call(foreign.handler, foreign.actor, "messaging/channels/respond", {
|
||||
membershipId: "cm-1",
|
||||
accept: true,
|
||||
});
|
||||
expect(response.status).toBeGreaterThanOrEqual(400);
|
||||
|
||||
const already = messagingDeps({
|
||||
membership: {
|
||||
id: "cm-1",
|
||||
channelId: "ch-1",
|
||||
identityId: "mi-1",
|
||||
status: "approved",
|
||||
channel: { id: "ch-1", provider: "sendblue", name: "Family", members: [] },
|
||||
},
|
||||
});
|
||||
const second = await call(already.handler, already.actor, "messaging/channels/respond", {
|
||||
membershipId: "cm-1",
|
||||
accept: true,
|
||||
});
|
||||
expect(second.status).toBeGreaterThanOrEqual(400);
|
||||
});
|
||||
|
||||
it("leaves an approved channel", async () => {
|
||||
const { handler, actor, prisma } = messagingDeps({
|
||||
membership: {
|
||||
id: "cm-1",
|
||||
channelId: "ch-1",
|
||||
identityId: "mi-1",
|
||||
status: "approved",
|
||||
channel: { id: "ch-1", provider: "sendblue", name: "Family", members: [] },
|
||||
},
|
||||
});
|
||||
const response = await call(handler, actor, "messaging/channels/leave", {
|
||||
membershipId: "cm-1",
|
||||
});
|
||||
expect(prisma.messagingChannelMember.update).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ data: { status: "left" } }),
|
||||
);
|
||||
await expect(response.json()).resolves.toEqual({ json: { ok: true } });
|
||||
});
|
||||
|
||||
it("answers the named membership when one user has two chat apps in a group", async () => {
|
||||
const channel = {
|
||||
id: "ch-1",
|
||||
provider: "sendblue",
|
||||
name: "Family",
|
||||
members: [{ id: "cm-1" }, { id: "cm-2" }],
|
||||
};
|
||||
const { handler, actor, prisma } = messagingDeps({
|
||||
identities: [identity, { ...identity, id: "mi-2", address: "+15552222222", botId: "bot-2" }],
|
||||
memberships: [
|
||||
// Same user, same group, one membership per linked chat app. Answering
|
||||
// by channel would pick an arbitrary one and leave the other live.
|
||||
{ id: "cm-work", channelId: "ch-1", identityId: "mi-1", status: "approved", channel },
|
||||
{ id: "cm-home", channelId: "ch-1", identityId: "mi-2", status: "invited", channel },
|
||||
],
|
||||
});
|
||||
|
||||
const approved = await call(handler, actor, "messaging/channels/respond", {
|
||||
membershipId: "cm-home",
|
||||
accept: true,
|
||||
});
|
||||
expect(prisma.messagingChannelMember.updateMany).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ where: { id: "cm-home", status: "invited" } }),
|
||||
);
|
||||
await expect(approved.json()).resolves.toEqual({
|
||||
json: expect.objectContaining({ id: "cm-home", identityId: "mi-2", status: "approved" }),
|
||||
});
|
||||
|
||||
const listed = await call(handler, actor, "messaging/channels/list");
|
||||
const rows = (await listed.json()).json as Array<{ id: string; channelId: string }>;
|
||||
expect(rows.map((row) => row.id)).toEqual(["cm-work", "cm-home"]);
|
||||
expect(new Set(rows.map((row) => row.channelId))).toEqual(new Set(["ch-1"]));
|
||||
});
|
||||
});
|
||||
|
||||
describe("messaging.connections", () => {
|
||||
it("lists connections with the peer label and direction", async () => {
|
||||
const { handler, actor } = messagingDeps();
|
||||
const response = await call(handler, actor, "messaging/connections/list");
|
||||
await expect(response.json()).resolves.toEqual({
|
||||
json: [
|
||||
{
|
||||
id: "ac-1",
|
||||
peerBotName: "Helper",
|
||||
peerOwnerLabel: "Bob",
|
||||
status: "pending",
|
||||
incoming: true,
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it("hides peer identity for outgoing connections that are not approved", async () => {
|
||||
const { handler, actor } = messagingDeps({
|
||||
connection: { id: "ac-4", requesterBotId: "bot-1", targetBotId: "bot-9", status: "pending" },
|
||||
});
|
||||
const response = await call(handler, actor, "messaging/connections/list");
|
||||
await expect(response.json()).resolves.toEqual({
|
||||
json: [
|
||||
expect.objectContaining({ peerBotName: "agent", peerOwnerLabel: "owner", incoming: false }),
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it("approves a pending incoming connection and notifies the requester", async () => {
|
||||
const { handler, actor, prisma, outboundRows, enqueue } = messagingDeps();
|
||||
const response = await call(handler, actor, "messaging/connections/respond", {
|
||||
connectionId: "ac-1",
|
||||
accept: true,
|
||||
});
|
||||
expect(prisma.agentConnection.updateMany).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
where: { id: "ac-1", status: "pending" },
|
||||
data: { status: "approved" },
|
||||
}),
|
||||
);
|
||||
await expect(response.json()).resolves.toEqual({
|
||||
json: expect.objectContaining({ status: "approved" }),
|
||||
});
|
||||
expect(outboundRows).toEqual([
|
||||
expect.objectContaining({
|
||||
idempotencyKey: "command:connected:ac-1",
|
||||
kind: "dm",
|
||||
identityId: "mi-9",
|
||||
}),
|
||||
]);
|
||||
expect(enqueue).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects respond from the requester side", async () => {
|
||||
const outgoing = messagingDeps({
|
||||
connection: { id: "ac-2", requesterBotId: "bot-1", targetBotId: "bot-9", status: "pending" },
|
||||
});
|
||||
const response = await call(outgoing.handler, outgoing.actor, "messaging/connections/respond", {
|
||||
connectionId: "ac-2",
|
||||
accept: true,
|
||||
});
|
||||
expect(response.status).toBeGreaterThanOrEqual(400);
|
||||
});
|
||||
|
||||
it("revokes an approved connection from either side", async () => {
|
||||
const { handler, actor, prisma } = messagingDeps({
|
||||
connection: { id: "ac-3", requesterBotId: "bot-1", targetBotId: "bot-9", status: "approved" },
|
||||
});
|
||||
const response = await call(handler, actor, "messaging/connections/revoke", {
|
||||
connectionId: "ac-3",
|
||||
});
|
||||
expect(prisma.agentConnection.updateMany).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
where: { id: "ac-3", status: "approved" },
|
||||
data: { status: "revoked" },
|
||||
}),
|
||||
);
|
||||
await expect(response.json()).resolves.toEqual({ json: { ok: true } });
|
||||
});
|
||||
|
||||
it("cancels a pending connect invite when revoking a re-requested connection", async () => {
|
||||
const { handler, actor, prisma, outboundRows } = messagingDeps({
|
||||
connection: { id: "ac-4", requesterBotId: "bot-1", targetBotId: "bot-9", status: "pending" },
|
||||
});
|
||||
outboundRows.push({
|
||||
idempotencyKey: "connect:bot-1:bot-9",
|
||||
kind: "dm",
|
||||
identityId: "mi-9",
|
||||
body: "wants to connect",
|
||||
status: "pending",
|
||||
});
|
||||
outboundRows.push({
|
||||
idempotencyKey: "connect:bot-1:bot-9-already-sent",
|
||||
kind: "dm",
|
||||
status: "sent",
|
||||
});
|
||||
|
||||
const response = await call(handler, actor, "messaging/connections/revoke", {
|
||||
connectionId: "ac-4",
|
||||
});
|
||||
|
||||
expect(prisma.messagingOutbound.deleteMany).toHaveBeenCalledWith({
|
||||
where: {
|
||||
idempotencyKey: "connect:bot-1:bot-9",
|
||||
OR: [{ status: "pending" }, { status: "sent", providerHandle: null }],
|
||||
},
|
||||
});
|
||||
expect(outboundRows).toEqual([
|
||||
expect.objectContaining({
|
||||
idempotencyKey: "connect:bot-1:bot-9-already-sent",
|
||||
status: "sent",
|
||||
}),
|
||||
]);
|
||||
await expect(response.json()).resolves.toEqual({ json: { ok: true } });
|
||||
});
|
||||
|
||||
it("cancels a claimed-but-undelivered connect invite on revoke", async () => {
|
||||
const { handler, actor, outboundRows } = messagingDeps({
|
||||
connection: {
|
||||
id: "ac-claimed",
|
||||
requesterBotId: "bot-1",
|
||||
targetBotId: "bot-9",
|
||||
status: "pending",
|
||||
},
|
||||
});
|
||||
outboundRows.push({
|
||||
idempotencyKey: "connect:bot-1:bot-9",
|
||||
kind: "dm",
|
||||
status: "sent",
|
||||
providerHandle: null,
|
||||
body: "claimed by drain",
|
||||
});
|
||||
outboundRows.push({
|
||||
idempotencyKey: "connect:bot-1:bot-9-delivered",
|
||||
kind: "dm",
|
||||
status: "sent",
|
||||
providerHandle: "h-delivered",
|
||||
});
|
||||
|
||||
const response = await call(handler, actor, "messaging/connections/revoke", {
|
||||
connectionId: "ac-claimed",
|
||||
});
|
||||
|
||||
expect(outboundRows).toEqual([
|
||||
expect.objectContaining({
|
||||
idempotencyKey: "connect:bot-1:bot-9-delivered",
|
||||
providerHandle: "h-delivered",
|
||||
}),
|
||||
]);
|
||||
await expect(response.json()).resolves.toEqual({ json: { ok: true } });
|
||||
});
|
||||
|
||||
it("revokes status and cancels invites in one transaction", async () => {
|
||||
const { handler, actor, prisma, outboundRows } = messagingDeps({
|
||||
connection: { id: "ac-5", requesterBotId: "bot-1", targetBotId: "bot-9", status: "pending" },
|
||||
});
|
||||
outboundRows.push({
|
||||
idempotencyKey: "connect:bot-1:bot-9",
|
||||
kind: "dm",
|
||||
status: "pending",
|
||||
});
|
||||
const order: string[] = [];
|
||||
const connectionModel = prisma.agentConnection as unknown as {
|
||||
updateMany: (args: unknown) => Promise<{ count: number }>;
|
||||
};
|
||||
const baseUpdateMany = connectionModel.updateMany.bind(connectionModel);
|
||||
const baseDeleteMany = prisma.messagingOutbound.deleteMany;
|
||||
(prisma as unknown as Record<string, unknown>).$transaction = vi.fn(
|
||||
async (fn: (tx: unknown) => Promise<unknown>) => {
|
||||
order.push("tx-start");
|
||||
const result = await fn({
|
||||
agentConnection: {
|
||||
updateMany: async (args: unknown) => {
|
||||
order.push("update");
|
||||
return baseUpdateMany(args);
|
||||
},
|
||||
},
|
||||
messagingOutbound: {
|
||||
deleteMany: async (args: unknown) => {
|
||||
order.push("delete");
|
||||
return baseDeleteMany(args as never);
|
||||
},
|
||||
},
|
||||
});
|
||||
order.push("tx-end");
|
||||
return result;
|
||||
},
|
||||
);
|
||||
|
||||
const response = await call(handler, actor, "messaging/connections/revoke", {
|
||||
connectionId: "ac-5",
|
||||
});
|
||||
|
||||
expect(order).toEqual(["tx-start", "update", "delete", "tx-end"]);
|
||||
expect(outboundRows).toEqual([]);
|
||||
await expect(response.json()).resolves.toEqual({ json: { ok: true } });
|
||||
});
|
||||
|
||||
it("does not delete a reconnect invite created after revoke commits", async () => {
|
||||
const { handler, actor, prisma, outboundRows } = messagingDeps({
|
||||
connection: { id: "ac-6", requesterBotId: "bot-1", targetBotId: "bot-9", status: "pending" },
|
||||
});
|
||||
outboundRows.push({
|
||||
idempotencyKey: "connect:bot-1:bot-9",
|
||||
kind: "dm",
|
||||
status: "pending",
|
||||
body: "old invite",
|
||||
});
|
||||
const connectionModel = prisma.agentConnection as unknown as {
|
||||
updateMany: (args: unknown) => Promise<{ count: number }>;
|
||||
};
|
||||
const baseUpdateMany = connectionModel.updateMany.bind(connectionModel);
|
||||
const baseDeleteMany = prisma.messagingOutbound.deleteMany;
|
||||
(prisma as unknown as Record<string, unknown>).$transaction = vi.fn(
|
||||
async (fn: (tx: unknown) => Promise<unknown>) =>
|
||||
fn({
|
||||
agentConnection: {
|
||||
updateMany: async (args: unknown) => baseUpdateMany(args),
|
||||
},
|
||||
messagingOutbound: {
|
||||
deleteMany: async (args: unknown) => baseDeleteMany(args as never),
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
await call(handler, actor, "messaging/connections/revoke", { connectionId: "ac-6" });
|
||||
expect(outboundRows).toEqual([]);
|
||||
|
||||
// Reconnect after revoke has committed: its fresh invite must survive.
|
||||
outboundRows.push({
|
||||
idempotencyKey: "connect:bot-1:bot-9",
|
||||
kind: "dm",
|
||||
status: "pending",
|
||||
body: "fresh reconnect invite",
|
||||
});
|
||||
expect(prisma.messagingOutbound.deleteMany).toHaveBeenCalledTimes(1);
|
||||
expect(outboundRows).toEqual([
|
||||
expect.objectContaining({
|
||||
idempotencyKey: "connect:bot-1:bot-9",
|
||||
body: "fresh reconnect invite",
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
it("does not let a stale revoke overwrite a newer pending re-request", async () => {
|
||||
const { handler, actor, prisma } = messagingDeps({
|
||||
connection: {
|
||||
id: "ac-stale",
|
||||
requesterBotId: "bot-1",
|
||||
targetBotId: "bot-9",
|
||||
status: "approved",
|
||||
},
|
||||
});
|
||||
const state = { status: "approved" };
|
||||
const connectionModel = prisma.agentConnection as unknown as Record<string, unknown>;
|
||||
connectionModel.findFirst = vi.fn(async () => {
|
||||
const snapshot = {
|
||||
id: "ac-stale",
|
||||
requesterBotId: "bot-1",
|
||||
targetBotId: "bot-9",
|
||||
status: state.status,
|
||||
};
|
||||
// Interleaved: a new request flips the row back to pending after the read.
|
||||
state.status = "pending";
|
||||
return snapshot;
|
||||
});
|
||||
connectionModel.updateMany = vi.fn(
|
||||
async ({ where, data }: { where: { status?: string }; data: Record<string, unknown> }) => {
|
||||
if (where.status && state.status !== where.status) return { count: 0 };
|
||||
Object.assign(state, data);
|
||||
return { count: 1 };
|
||||
},
|
||||
);
|
||||
const response = await call(handler, actor, "messaging/connections/revoke", {
|
||||
connectionId: "ac-stale",
|
||||
});
|
||||
|
||||
expect(state.status).toBe("pending");
|
||||
expect(response.status).toBeGreaterThanOrEqual(400);
|
||||
});
|
||||
});
|
||||
|
||||
describe("messaging status-write races", () => {
|
||||
it("does not overwrite a concurrent revoke when responding to a connection", async () => {
|
||||
const { handler, actor, prisma, outboundRows } = messagingDeps();
|
||||
const state = { status: "pending" };
|
||||
const connectionModel = prisma.agentConnection as unknown as Record<string, unknown>;
|
||||
connectionModel.findFirst = vi.fn(async () => {
|
||||
const snapshot = {
|
||||
id: "ac-1",
|
||||
requesterBotId: "bot-9",
|
||||
targetBotId: "bot-1",
|
||||
status: state.status,
|
||||
};
|
||||
// Interleaved: the requester revokes between the read and the write.
|
||||
state.status = "revoked";
|
||||
return snapshot;
|
||||
});
|
||||
connectionModel.update = vi.fn(async ({ data }: { data: Record<string, unknown> }) => {
|
||||
Object.assign(state, data);
|
||||
return state;
|
||||
});
|
||||
connectionModel.updateMany = vi.fn(
|
||||
async ({ where, data }: { where: { status?: string }; data: Record<string, unknown> }) => {
|
||||
if (where.status && state.status !== where.status) return { count: 0 };
|
||||
Object.assign(state, data);
|
||||
return { count: 1 };
|
||||
},
|
||||
);
|
||||
const response = await call(handler, actor, "messaging/connections/respond", {
|
||||
connectionId: "ac-1",
|
||||
accept: true,
|
||||
});
|
||||
|
||||
expect(state.status).toBe("revoked");
|
||||
expect(response.status).toBeGreaterThanOrEqual(400);
|
||||
expect(outboundRows).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("does not overwrite a concurrent leave when responding to a channel invite", async () => {
|
||||
const { handler, actor, prisma } = messagingDeps();
|
||||
const state = { status: "invited" };
|
||||
const memberModel = prisma.messagingChannelMember as unknown as Record<string, unknown>;
|
||||
memberModel.findFirst = vi.fn(async () => {
|
||||
const snapshot = {
|
||||
id: "cm-1",
|
||||
channelId: "ch-1",
|
||||
address: "+15551111111",
|
||||
identityId: "mi-1",
|
||||
status: state.status,
|
||||
channel: { id: "ch-1", provider: "sendblue", name: "Family", members: [{ id: "cm-1" }] },
|
||||
};
|
||||
// Interleaved: the owner left (or was swept out) after the read.
|
||||
state.status = "left";
|
||||
return snapshot;
|
||||
});
|
||||
memberModel.update = vi.fn(async ({ data }: { data: Record<string, unknown> }) => {
|
||||
Object.assign(state, data);
|
||||
return state;
|
||||
});
|
||||
memberModel.updateMany = vi.fn(
|
||||
async ({ where, data }: { where: { status?: string }; data: Record<string, unknown> }) => {
|
||||
if (where.status && state.status !== where.status) return { count: 0 };
|
||||
Object.assign(state, data);
|
||||
return { count: 1 };
|
||||
},
|
||||
);
|
||||
const response = await call(handler, actor, "messaging/channels/respond", {
|
||||
membershipId: "cm-1",
|
||||
accept: true,
|
||||
});
|
||||
|
||||
expect(state.status).toBe("left");
|
||||
expect(response.status).toBeGreaterThanOrEqual(400);
|
||||
});
|
||||
});
|
||||
|
||||
describe("messaging.connections.respond confirmation atomicity", () => {
|
||||
it("writes the requester confirmation under the claim's transaction", async () => {
|
||||
const { handler, actor, prisma, outboundRows } = messagingDeps();
|
||||
// Track the transaction-scoped outbox delegate separately: a revoke can
|
||||
// only be excluded from the confirmation window if the claim's row lock
|
||||
// is still held when the confirmation row is written.
|
||||
const txCreateMany = vi.fn(async ({ data }: { data: Array<Record<string, unknown>> }) => {
|
||||
outboundRows.push(...data);
|
||||
return { count: data.length };
|
||||
});
|
||||
(prisma as unknown as Record<string, unknown>).$transaction = vi.fn(
|
||||
async (fn: (tx: unknown) => Promise<unknown>) =>
|
||||
fn({
|
||||
agentConnection: prisma.agentConnection,
|
||||
messagingIdentity: prisma.messagingIdentity,
|
||||
messagingOutbound: {
|
||||
createMany: txCreateMany,
|
||||
deleteMany: vi.fn(async () => ({ count: 0 })),
|
||||
},
|
||||
}),
|
||||
);
|
||||
const response = await call(handler, actor, "messaging/connections/respond", {
|
||||
connectionId: "ac-1",
|
||||
accept: true,
|
||||
});
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(txCreateMany).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
data: [expect.objectContaining({ idempotencyKey: "command:connected:ac-1" })],
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,479 @@
|
|||
import { RPCHandler } from "@orpc/server/fetch";
|
||||
import type { Actor } from "@rakazo/contracts";
|
||||
import type { PrismaClient } from "@rakazo/db";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { createRouter, type RouterDeps } from "./router.js";
|
||||
|
||||
describe("account preferences", () => {
|
||||
function preferencesDeps(avatarStyle: string) {
|
||||
const update = vi.fn().mockResolvedValue({});
|
||||
const prisma = {
|
||||
user: {
|
||||
update,
|
||||
findUniqueOrThrow: vi.fn().mockResolvedValue({
|
||||
email: "user@rakazo.test",
|
||||
name: "Test User",
|
||||
avatarStyle,
|
||||
}),
|
||||
},
|
||||
spaceModelPreference: { findFirst: vi.fn().mockResolvedValue(null) },
|
||||
deploymentSettings: { findUnique: vi.fn().mockResolvedValue(null) },
|
||||
} as unknown as PrismaClient;
|
||||
const deps = {
|
||||
prisma,
|
||||
env: {
|
||||
defaultProvider: "fake",
|
||||
defaultModel: "fake-model",
|
||||
webOrigin: "http://127.0.0.1:5173",
|
||||
screenProxySecret: "fake-test-secret",
|
||||
sandboxProvider: "fake",
|
||||
},
|
||||
dataDir: "/tmp/rakazo-router-test",
|
||||
} as unknown as RouterDeps;
|
||||
const actor = {
|
||||
spaceId: "workspace-1",
|
||||
userId: "user-1",
|
||||
email: "user@rakazo.test",
|
||||
isDeploymentOwner: true,
|
||||
} satisfies Actor;
|
||||
return { update, deps, actor, handler: new RPCHandler(createRouter(deps)) };
|
||||
}
|
||||
|
||||
it("persists and returns the selected avatar style", async () => {
|
||||
const { update, actor, handler } = preferencesDeps("organic");
|
||||
|
||||
const { response } = await handler.handle(
|
||||
new Request("http://127.0.0.1/rpc/preferences/update", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ json: { avatarStyle: "organic" } }),
|
||||
}),
|
||||
{ prefix: "/rpc", context: { actor } },
|
||||
);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(update).toHaveBeenCalledWith({
|
||||
where: { id: "user-1" },
|
||||
data: { avatarStyle: "organic" },
|
||||
});
|
||||
await expect(response.json()).resolves.toEqual({
|
||||
json: expect.objectContaining({ avatarStyle: "organic" }),
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects avatar styles outside robot|organic", async () => {
|
||||
const { update, actor, handler } = preferencesDeps("robot");
|
||||
|
||||
const { response } = await handler.handle(
|
||||
new Request("http://127.0.0.1/rpc/preferences/update", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ json: { avatarStyle: "dicebear" } }),
|
||||
}),
|
||||
{ prefix: "/rpc", context: { actor } },
|
||||
);
|
||||
|
||||
expect(response.status).toBeGreaterThanOrEqual(400);
|
||||
expect(update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("coerces unknown stored avatar styles to organic on me", async () => {
|
||||
const { actor, handler } = preferencesDeps("custom-cdn");
|
||||
|
||||
const { response } = await handler.handle(
|
||||
new Request("http://127.0.0.1/rpc/me", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ json: null }),
|
||||
}),
|
||||
{ prefix: "/rpc", context: { actor } },
|
||||
);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
await expect(response.json()).resolves.toEqual({
|
||||
json: expect.objectContaining({ avatarStyle: "organic" }),
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("thread answer delivery", () => {
|
||||
it("accepts a durable answer when the immediate worker wake fails", async () => {
|
||||
const answerRunInput = vi.fn().mockResolvedValue(true);
|
||||
const enqueue = vi.fn().mockRejectedValue(new Error("job broker unavailable"));
|
||||
const logError = vi.spyOn(console, "error").mockImplementation(() => undefined);
|
||||
const prisma = {
|
||||
bot: {
|
||||
findFirst: vi.fn().mockResolvedValue({
|
||||
id: "bot-1",
|
||||
thread: { id: "thread-1" },
|
||||
computer: null,
|
||||
}),
|
||||
},
|
||||
} as unknown as PrismaClient;
|
||||
const deps = {
|
||||
prisma,
|
||||
events: { answerRunInput },
|
||||
jobs: { enqueue },
|
||||
env: {
|
||||
defaultProvider: "fake",
|
||||
defaultModel: "fake-model",
|
||||
webOrigin: "http://127.0.0.1:5173",
|
||||
screenProxySecret: "fake-test-secret",
|
||||
sandboxProvider: "fake",
|
||||
},
|
||||
dataDir: "/tmp/rakazo-router-test",
|
||||
} as unknown as RouterDeps;
|
||||
const actor = {
|
||||
spaceId: "workspace-1",
|
||||
userId: "user-1",
|
||||
email: "user@rakazo.test",
|
||||
isDeploymentOwner: true,
|
||||
} satisfies Actor;
|
||||
const handler = new RPCHandler(createRouter(deps));
|
||||
|
||||
const { matched, response } = await handler.handle(
|
||||
new Request("http://127.0.0.1/rpc/threads/answer", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
json: {
|
||||
botId: "bot-1",
|
||||
runId: "run-1",
|
||||
messageId: "message-1",
|
||||
answer: "Paris",
|
||||
},
|
||||
}),
|
||||
}),
|
||||
{ prefix: "/rpc", context: { actor } },
|
||||
);
|
||||
|
||||
expect(matched).toBe(true);
|
||||
expect(response.status).toBe(200);
|
||||
await expect(response.json()).resolves.toEqual({ json: { ok: true } });
|
||||
expect(answerRunInput).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
spaceId: "workspace-1",
|
||||
threadId: "thread-1",
|
||||
runId: "run-1",
|
||||
}),
|
||||
);
|
||||
expect(enqueue).toHaveBeenCalledOnce();
|
||||
expect(logError).toHaveBeenCalledWith("thread answer enqueue", expect.any(Error));
|
||||
logError.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
describe("MCP server deletion", () => {
|
||||
it("does not fail when a concurrent credential rotation already removed the old secret", async () => {
|
||||
const deleteServer = vi.fn().mockResolvedValue({ id: "server-1" });
|
||||
const deleteSecrets = vi.fn().mockResolvedValue({ count: 0 });
|
||||
const prisma = {
|
||||
mcpServer: {
|
||||
findFirst: vi.fn().mockResolvedValue({ id: "server-1", secretId: "old-secret" }),
|
||||
delete: deleteServer,
|
||||
},
|
||||
secret: { deleteMany: deleteSecrets },
|
||||
$transaction: vi.fn((operations: Promise<unknown>[]) => Promise.all(operations)),
|
||||
} as unknown as PrismaClient;
|
||||
const deps = {
|
||||
prisma,
|
||||
env: {
|
||||
defaultProvider: "fake",
|
||||
defaultModel: "fake-model",
|
||||
webOrigin: "http://127.0.0.1:5173",
|
||||
screenProxySecret: "fake-test-secret",
|
||||
sandboxProvider: "fake",
|
||||
},
|
||||
dataDir: "/tmp/rakazo-router-test",
|
||||
} as unknown as RouterDeps;
|
||||
const actor = {
|
||||
spaceId: "workspace-1",
|
||||
userId: "user-1",
|
||||
email: "user@rakazo.test",
|
||||
isDeploymentOwner: true,
|
||||
} satisfies Actor;
|
||||
const handler = new RPCHandler(createRouter(deps));
|
||||
|
||||
const { matched, response } = await handler.handle(
|
||||
new Request("http://127.0.0.1/rpc/mcp/servers/remove", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ json: { id: "server-1" } }),
|
||||
}),
|
||||
{ prefix: "/rpc", context: { actor } },
|
||||
);
|
||||
|
||||
expect(matched).toBe(true);
|
||||
expect(response.status).toBe(200);
|
||||
await expect(response.json()).resolves.toEqual({ json: { ok: true } });
|
||||
expect(deleteServer).toHaveBeenCalledWith({ where: { id: "server-1" } });
|
||||
expect(deleteSecrets).toHaveBeenCalledWith({
|
||||
where: {
|
||||
id: "old-secret",
|
||||
spaceId: "workspace-1",
|
||||
userId: "user-1",
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("connections.complete", () => {
|
||||
it("forwards an optional code to the managed connector", async () => {
|
||||
const complete = vi.fn().mockResolvedValue({ connectionRef: "gmail" });
|
||||
const connectionReady = vi.fn().mockResolvedValue(true);
|
||||
const update = vi.fn().mockResolvedValue({
|
||||
id: "conn-1",
|
||||
connectorId: "composio",
|
||||
provider: "gmail",
|
||||
displayName: "Gmail",
|
||||
status: "connected",
|
||||
createdAt: new Date("2026-08-26T00:00:00.000Z"),
|
||||
});
|
||||
const prisma = {
|
||||
connection: {
|
||||
findFirst: vi.fn().mockResolvedValue({
|
||||
id: "conn-1",
|
||||
connectorId: "composio",
|
||||
provider: "gmail",
|
||||
displayName: "Gmail",
|
||||
providerRef: "gmail-state",
|
||||
status: "pending",
|
||||
createdAt: new Date("2026-08-26T00:00:00.000Z"),
|
||||
}),
|
||||
update,
|
||||
},
|
||||
} as unknown as PrismaClient;
|
||||
const deps = {
|
||||
prisma,
|
||||
connectors: {
|
||||
managed: vi.fn(() => ({ complete, connectionReady })),
|
||||
},
|
||||
env: {
|
||||
defaultProvider: "fake",
|
||||
defaultModel: "fake-model",
|
||||
webOrigin: "http://127.0.0.1:5173",
|
||||
screenProxySecret: "fake-test-secret",
|
||||
sandboxProvider: "fake",
|
||||
},
|
||||
dataDir: "/tmp/rakazo-router-test",
|
||||
} as unknown as RouterDeps;
|
||||
const actor = {
|
||||
spaceId: "workspace-1",
|
||||
userId: "user-1",
|
||||
email: "user@rakazo.test",
|
||||
isDeploymentOwner: true,
|
||||
} satisfies Actor;
|
||||
const handler = new RPCHandler(createRouter(deps));
|
||||
|
||||
const { matched, response } = await handler.handle(
|
||||
new Request("http://127.0.0.1/rpc/connections/complete", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
json: {
|
||||
connectionId: "conn-1",
|
||||
code: "123456",
|
||||
},
|
||||
}),
|
||||
}),
|
||||
{ prefix: "/rpc", context: { actor } },
|
||||
);
|
||||
|
||||
expect(matched).toBe(true);
|
||||
expect(response.status).toBe(200);
|
||||
expect(complete).toHaveBeenCalledWith(
|
||||
{ state: "gmail-state", code: "123456" },
|
||||
expect.objectContaining({ spaceId: "workspace-1", userId: "user-1" }),
|
||||
);
|
||||
expect(connectionReady).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("updater owner gate", () => {
|
||||
function updaterDeps() {
|
||||
const prisma = {
|
||||
user: {
|
||||
findUniqueOrThrow: vi.fn().mockResolvedValue({
|
||||
email: "user@rakazo.test",
|
||||
name: "Test User",
|
||||
avatarStyle: "robot",
|
||||
}),
|
||||
},
|
||||
spaceModelPreference: { findFirst: vi.fn().mockResolvedValue(null) },
|
||||
deploymentSettings: { findUnique: vi.fn().mockResolvedValue(null) },
|
||||
} as unknown as PrismaClient;
|
||||
const deps = {
|
||||
prisma,
|
||||
env: {
|
||||
defaultProvider: "fake",
|
||||
defaultModel: "fake-model",
|
||||
webOrigin: "http://127.0.0.1:5173",
|
||||
screenProxySecret: "fake-test-secret",
|
||||
sandboxProvider: "fake",
|
||||
gitSha: "deadbeef",
|
||||
updaterUrl: undefined,
|
||||
updaterToken: undefined,
|
||||
},
|
||||
dataDir: "/tmp/rakazo-router-test",
|
||||
} as unknown as RouterDeps;
|
||||
return { deps, handler: new RPCHandler(createRouter(deps)) };
|
||||
}
|
||||
|
||||
it("forbids non-owners from updater status", async () => {
|
||||
const { handler } = updaterDeps();
|
||||
const actor = {
|
||||
spaceId: "workspace-1",
|
||||
userId: "user-2",
|
||||
email: "member@rakazo.test",
|
||||
isDeploymentOwner: false,
|
||||
} satisfies Actor;
|
||||
|
||||
const { response } = await handler.handle(
|
||||
new Request("http://127.0.0.1/rpc/updater/status", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ json: null }),
|
||||
}),
|
||||
{ prefix: "/rpc", context: { actor } },
|
||||
);
|
||||
|
||||
expect(response.status).toBe(403);
|
||||
});
|
||||
|
||||
it("lets the deployment owner read status without applying git", async () => {
|
||||
const { handler } = updaterDeps();
|
||||
const actor = {
|
||||
spaceId: "workspace-1",
|
||||
userId: "user-1",
|
||||
email: "owner@rakazo.test",
|
||||
isDeploymentOwner: true,
|
||||
} satisfies Actor;
|
||||
|
||||
const { response } = await handler.handle(
|
||||
new Request("http://127.0.0.1/rpc/updater/status", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ json: null }),
|
||||
}),
|
||||
{ prefix: "/rpc", context: { actor } },
|
||||
);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
const body = await response.json();
|
||||
expect(body.json.supported).toBe(false);
|
||||
expect(["source", "compose"]).toContain(body.json.installKind);
|
||||
expect(Array.isArray(body.json.manualCommands)).toBe(true);
|
||||
});
|
||||
|
||||
it("refuses apply when the sidecar is not configured", async () => {
|
||||
const { handler } = updaterDeps();
|
||||
const actor = {
|
||||
spaceId: "workspace-1",
|
||||
userId: "user-1",
|
||||
email: "owner@rakazo.test",
|
||||
isDeploymentOwner: true,
|
||||
} satisfies Actor;
|
||||
|
||||
const { response } = await handler.handle(
|
||||
new Request("http://127.0.0.1/rpc/updater/apply", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ json: {} }),
|
||||
}),
|
||||
{ prefix: "/rpc", context: { actor } },
|
||||
);
|
||||
|
||||
expect(response.status).toBeGreaterThanOrEqual(400);
|
||||
const body = await response.json();
|
||||
const message = JSON.stringify(body);
|
||||
expect(message).toMatch(/sidecar/i);
|
||||
expect(message).not.toMatch(/git (fetch|merge|pull)/i);
|
||||
});
|
||||
});
|
||||
|
||||
describe("computer screen url", () => {
|
||||
const actor = {
|
||||
spaceId: "workspace-1",
|
||||
userId: "user-1",
|
||||
email: "user@rakazo.test",
|
||||
isDeploymentOwner: true,
|
||||
} satisfies Actor;
|
||||
const computerRow = {
|
||||
id: "computer-1",
|
||||
kind: "e2b",
|
||||
scope: "team",
|
||||
state: "running",
|
||||
providerRef: "sandbox-ref-1",
|
||||
homeKey: "home-1",
|
||||
controlHolder: "none",
|
||||
controlLeaseId: null,
|
||||
controlLeaseExpiresAt: null,
|
||||
controlBotId: null,
|
||||
controlRunId: null,
|
||||
};
|
||||
|
||||
const callScreenUrl = async (connectScreen: () => Promise<unknown>, updateMany = vi.fn()) => {
|
||||
const prisma = {
|
||||
bot: {
|
||||
findFirst: vi.fn().mockResolvedValue({
|
||||
id: "bot-1",
|
||||
thread: { id: "thread-1" },
|
||||
computer: computerRow,
|
||||
}),
|
||||
},
|
||||
computer: { updateMany },
|
||||
computerExecutionLease: { findUnique: vi.fn().mockResolvedValue(null) },
|
||||
} as unknown as PrismaClient;
|
||||
const deps = {
|
||||
prisma,
|
||||
sandbox: { connectScreen },
|
||||
jobs: { enqueue: vi.fn().mockResolvedValue(undefined) },
|
||||
env: {
|
||||
defaultProvider: "fake",
|
||||
defaultModel: "fake-model",
|
||||
webOrigin: "http://127.0.0.1:5173",
|
||||
screenProxySecret: "fake-test-secret",
|
||||
sandboxProvider: "e2b",
|
||||
},
|
||||
dataDir: "/tmp/rakazo-router-test",
|
||||
} as unknown as RouterDeps;
|
||||
const handler = new RPCHandler(createRouter(deps));
|
||||
const { response } = await handler.handle(
|
||||
new Request("http://127.0.0.1/rpc/computer/screenUrl", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ json: { botId: "bot-1" } }),
|
||||
}),
|
||||
{ prefix: "/rpc", context: { actor } },
|
||||
);
|
||||
return { response, updateMany };
|
||||
};
|
||||
|
||||
it("clears the row instead of 500ing when the provider says the sandbox is gone", async () => {
|
||||
const logError = vi.spyOn(console, "error").mockImplementation(() => undefined);
|
||||
const { response, updateMany } = await callScreenUrl(() =>
|
||||
Promise.reject(
|
||||
Object.assign(new Error("Sandbox is probably not running anymore"), {
|
||||
name: "SandboxNotFoundError",
|
||||
}),
|
||||
),
|
||||
);
|
||||
expect(response.status).toBe(200);
|
||||
await expect(response.json()).resolves.toEqual({ json: { url: null } });
|
||||
expect(updateMany).toHaveBeenCalledWith({
|
||||
where: { id: "computer-1", providerRef: "sandbox-ref-1" },
|
||||
data: { state: "stopped", providerRef: null },
|
||||
});
|
||||
logError.mockRestore();
|
||||
});
|
||||
|
||||
it("keeps a transport blip an error and leaves the row alone", async () => {
|
||||
const logError = vi.spyOn(console, "error").mockImplementation(() => undefined);
|
||||
const { response, updateMany } = await callScreenUrl(() =>
|
||||
Promise.reject(Object.assign(new Error("fetch failed"), { code: "ECONNRESET" })),
|
||||
);
|
||||
expect(response.status).toBe(500);
|
||||
expect(updateMany).not.toHaveBeenCalled();
|
||||
logError.mockRestore();
|
||||
});
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
|
|
@ -0,0 +1,39 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import { activityNotificationsEnabled, activityPromptSnippet } from "./runs.js";
|
||||
|
||||
describe("run activity copy", () => {
|
||||
it("presents structured agent messages instead of their internal wake prompt", () => {
|
||||
expect(
|
||||
activityPromptSnippet({
|
||||
trigger: "bot_message",
|
||||
prompt: "[bot] A message just arrived from another bot with internal routing data",
|
||||
sourceBlocks: [
|
||||
{
|
||||
kind: "bot_message_received",
|
||||
fromBotId: "maya",
|
||||
fromBotName: "Maya",
|
||||
text: "Please check the release workflow.",
|
||||
intent: "request",
|
||||
},
|
||||
],
|
||||
}),
|
||||
).toBe("Maya asked: Please check the release workflow.");
|
||||
});
|
||||
|
||||
it("fails closed when an agent message has no valid structured source", () => {
|
||||
expect(
|
||||
activityPromptSnippet({
|
||||
trigger: "bot_message",
|
||||
prompt: "[bot] private internal routing envelope",
|
||||
sourceBlocks: [{ kind: "text", text: "not a peer message" }],
|
||||
}),
|
||||
).toBe("Message from another agent");
|
||||
});
|
||||
});
|
||||
|
||||
describe("run activity notification preference", () => {
|
||||
it("silences only direct messages", () => {
|
||||
expect(activityNotificationsEnabled(null, false)).toBe(false);
|
||||
expect(activityNotificationsEnabled("group-1", false)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,89 @@
|
|||
import { type Actor, MessageBlock, type RunActivityRow } from "@rakazo/contracts";
|
||||
import { ACTIVE_RUN_STATUSES, botMessageContext } from "@rakazo/core";
|
||||
import type { PrismaClient } from "@rakazo/db";
|
||||
|
||||
const RECENT_LIMIT = 20;
|
||||
const TERMINAL_STATUSES = ["completed", "failed", "cancelled"] as const;
|
||||
|
||||
function promptSnippet(prompt: string, max = 120): string {
|
||||
const oneLine = prompt.replace(/\s+/g, " ").trim();
|
||||
if (oneLine.length <= max) return oneLine;
|
||||
return `${oneLine.slice(0, max - 1)}…`;
|
||||
}
|
||||
|
||||
export function activityPromptSnippet(
|
||||
input: { trigger: string; prompt: string; sourceBlocks?: unknown },
|
||||
max = 120,
|
||||
): string {
|
||||
if (input.trigger !== "bot_message") return promptSnippet(input.prompt, max);
|
||||
const parsed = MessageBlock.array().safeParse(input.sourceBlocks);
|
||||
const message = parsed.success ? botMessageContext(parsed.data) : undefined;
|
||||
if (!message) return "Message from another agent";
|
||||
const name = message.fromBotName.trim() || "Another agent";
|
||||
const label =
|
||||
message.intent === "result" || message.intent === "status" || message.intent === "fyi"
|
||||
? `Update from ${name}`
|
||||
: `${name} asked`;
|
||||
return promptSnippet(message.text.trim() ? `${label}: ${message.text}` : label, max);
|
||||
}
|
||||
|
||||
export function activityNotificationsEnabled(
|
||||
groupId: string | null,
|
||||
notifyOnFinish: boolean,
|
||||
): boolean {
|
||||
return groupId !== null || notifyOnFinish;
|
||||
}
|
||||
|
||||
export async function listSpaceRuns(
|
||||
prisma: PrismaClient,
|
||||
actor: Actor,
|
||||
filter: "active" | "recent",
|
||||
): Promise<RunActivityRow[]> {
|
||||
const rows = await prisma.run.findMany({
|
||||
where: {
|
||||
spaceId: actor.spaceId,
|
||||
userId: actor.userId,
|
||||
bot: { archivedAt: null },
|
||||
...(filter === "active"
|
||||
? { status: { in: [...ACTIVE_RUN_STATUSES] } }
|
||||
: { status: { in: [...TERMINAL_STATUSES] } }),
|
||||
},
|
||||
include: {
|
||||
bot: { select: { name: true, archivedAt: true, notifyOnFinish: true } },
|
||||
task: { select: { prompt: true } },
|
||||
sourceMessage: { select: { blocks: true } },
|
||||
thread: {
|
||||
select: {
|
||||
groupId: true,
|
||||
group: { select: { name: true } },
|
||||
},
|
||||
},
|
||||
},
|
||||
orderBy:
|
||||
filter === "active"
|
||||
? [{ updatedAt: "desc" }, { id: "desc" }]
|
||||
: [{ completedAt: "desc" }, { updatedAt: "desc" }, { id: "desc" }],
|
||||
take: filter === "recent" ? RECENT_LIMIT : undefined,
|
||||
});
|
||||
|
||||
return rows.map((row) => ({
|
||||
runId: row.id,
|
||||
botId: row.botId,
|
||||
botName: row.bot.name,
|
||||
groupId: row.thread.groupId,
|
||||
groupName: row.thread.group?.name ?? null,
|
||||
threadId: row.threadId,
|
||||
status: row.status as RunActivityRow["status"],
|
||||
trigger: row.trigger as RunActivityRow["trigger"],
|
||||
notificationsEnabled: activityNotificationsEnabled(row.thread.groupId, row.bot.notifyOnFinish),
|
||||
promptSnippet: activityPromptSnippet({
|
||||
trigger: row.trigger,
|
||||
prompt: row.task.prompt,
|
||||
sourceBlocks: row.sourceMessage?.blocks,
|
||||
}),
|
||||
updatedAt: (filter === "recent" && row.completedAt
|
||||
? row.completedAt
|
||||
: row.updatedAt
|
||||
).toISOString(),
|
||||
}));
|
||||
}
|
||||
|
|
@ -0,0 +1,40 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import { addScreenProxyCapability } from "./screen-proxy.js";
|
||||
|
||||
describe("screen proxy capability", () => {
|
||||
it("signs loopback Docker screen URLs without changing their destination", () => {
|
||||
const result = new URL(
|
||||
addScreenProxyCapability(
|
||||
"http://127.0.0.1:49152/embed.html?view_only=true",
|
||||
"secret",
|
||||
"https://app.example",
|
||||
100,
|
||||
),
|
||||
);
|
||||
expect(result.origin).toBe("https://app.example");
|
||||
expect(result.pathname).toMatch(
|
||||
/^\/novnc\/[\w-]+\/49152\/view\/3600100\.[\w-]{43}\/embed\.html$/,
|
||||
);
|
||||
expect(result.searchParams.get("view_only")).toBe("true");
|
||||
});
|
||||
|
||||
it("does not modify managed-provider URLs", () => {
|
||||
const url = "https://sandbox.example/embed.html?token=provider-token";
|
||||
expect(addScreenProxyCapability(url, "secret", "https://app.example", 100)).toBe(url);
|
||||
});
|
||||
|
||||
it("keeps external desktop secrets behind an encrypted, policy-bound capability", () => {
|
||||
const result = new URL(
|
||||
addScreenProxyCapability(
|
||||
"https://box.example/vnc.html?token=provider-token&view_only=true",
|
||||
"secret",
|
||||
"https://app.example",
|
||||
100,
|
||||
{ proxyExternal: true },
|
||||
),
|
||||
);
|
||||
expect(result.origin).toBe("https://app.example");
|
||||
expect(result.pathname).toMatch(/^\/novnc\/remote\/view\/3600100\.[\w-]+\/vnc\.html$/);
|
||||
expect(result.toString()).not.toContain("provider-token");
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,58 @@
|
|||
import { createCipheriv, createHash, createHmac, randomBytes } from "node:crypto";
|
||||
|
||||
const SCREEN_PROXY_TTL_MS = 60 * 60_000;
|
||||
const SCREEN_PROXY_CIPHER = "aes-256-gcm";
|
||||
const SCREEN_PROXY_REMOTE_PREFIX = "/novnc/remote";
|
||||
|
||||
export interface ScreenProxyOptions {
|
||||
/** Keep provider desktop secrets server-side and enforce the view/control policy in the proxy. */
|
||||
proxyExternal?: boolean;
|
||||
}
|
||||
|
||||
export function addScreenProxyCapability(
|
||||
url: string,
|
||||
secret: string,
|
||||
proxyOrigin: string,
|
||||
now = Date.now(),
|
||||
options: ScreenProxyOptions = {},
|
||||
): string {
|
||||
try {
|
||||
const parsed = new URL(url);
|
||||
if (options.proxyExternal && parsed.protocol === "https:" && parsed.hostname) {
|
||||
const expiresAt = now + SCREEN_PROXY_TTL_MS;
|
||||
const policy = parsed.searchParams.get("view_only") === "false" ? "control" : "view";
|
||||
const token = sealScreenTarget(parsed.toString(), secret, policy, expiresAt);
|
||||
const origin = new URL(proxyOrigin).origin;
|
||||
return `${origin}${SCREEN_PROXY_REMOTE_PREFIX}/${policy}/${expiresAt}.${token}${parsed.pathname || "/"}`;
|
||||
}
|
||||
if (parsed.protocol !== "http:" || !parsed.hostname || !parsed.port) return url;
|
||||
const expiresAt = now + SCREEN_PROXY_TTL_MS;
|
||||
const target = Buffer.from(parsed.hostname).toString("base64url");
|
||||
const policy = parsed.searchParams.get("view_only") === "false" ? "control" : "view";
|
||||
const destination = `${parsed.pathname}${parsed.search}`;
|
||||
const signature = createHmac("sha256", secret)
|
||||
.update(`${parsed.hostname}:${parsed.port}:${policy}:${expiresAt}`)
|
||||
.digest("base64url");
|
||||
const origin = new URL(proxyOrigin).origin;
|
||||
return `${origin}/novnc/${target}/${parsed.port}/${policy}/${expiresAt}.${signature}${destination}`;
|
||||
} catch {
|
||||
return url;
|
||||
}
|
||||
}
|
||||
|
||||
function sealScreenTarget(
|
||||
url: string,
|
||||
secret: string,
|
||||
policy: "view" | "control",
|
||||
expiresAt: number,
|
||||
) {
|
||||
const iv = randomBytes(12);
|
||||
const cipher = createCipheriv(SCREEN_PROXY_CIPHER, screenProxyKey(secret), iv);
|
||||
cipher.setAAD(Buffer.from(`${policy}:${expiresAt}`));
|
||||
const ciphertext = Buffer.concat([cipher.update(url, "utf8"), cipher.final()]);
|
||||
return Buffer.concat([iv, cipher.getAuthTag(), ciphertext]).toString("base64url");
|
||||
}
|
||||
|
||||
function screenProxyKey(secret: string) {
|
||||
return createHash("sha256").update(secret).digest();
|
||||
}
|
||||
|
|
@ -0,0 +1,326 @@
|
|||
import type { Actor, MessageBlock, SearchHit } from "@rakazo/contracts";
|
||||
import { extractLinksFromText, matchesSearchQuery, snippetAroundMatch } from "@rakazo/core";
|
||||
import type { Prisma, PrismaClient } from "@rakazo/db";
|
||||
|
||||
const SEARCH_LIMIT = 25;
|
||||
/** Cap name matches so content hits (messages/files/links/routines) keep most of the budget. */
|
||||
const CONVERSATION_HIT_LIMIT = 5;
|
||||
|
||||
export async function querySpaceSearch(
|
||||
prisma: PrismaClient,
|
||||
actor: Actor,
|
||||
q: string,
|
||||
): Promise<SearchHit[]> {
|
||||
const query = q.trim();
|
||||
if (!query) return [];
|
||||
|
||||
const conversationHits: SearchHit[] = [];
|
||||
const contentHits: SearchHit[] = [];
|
||||
const seen = new Set<string>();
|
||||
|
||||
function pushInto(bucket: SearchHit[], hit: SearchHit, limit: number) {
|
||||
const key = [
|
||||
hit.kind,
|
||||
hit.botId ?? "",
|
||||
hit.groupId ?? "",
|
||||
hit.messageId ?? "",
|
||||
hit.artifactId ?? "",
|
||||
hit.routineId ?? "",
|
||||
hit.url ?? "",
|
||||
].join(":");
|
||||
if (seen.has(key) || bucket.length >= limit) return;
|
||||
seen.add(key);
|
||||
bucket.push(hit);
|
||||
}
|
||||
|
||||
const bots = await prisma.bot.findMany({
|
||||
where: {
|
||||
spaceId: actor.spaceId,
|
||||
userId: actor.userId,
|
||||
archivedAt: null,
|
||||
OR: [
|
||||
{ name: { contains: query, mode: "insensitive" } },
|
||||
{ title: { contains: query, mode: "insensitive" } },
|
||||
{ description: { contains: query, mode: "insensitive" } },
|
||||
],
|
||||
},
|
||||
take: CONVERSATION_HIT_LIMIT,
|
||||
});
|
||||
for (const bot of bots) {
|
||||
pushInto(
|
||||
conversationHits,
|
||||
{
|
||||
kind: "conversation",
|
||||
botId: bot.id,
|
||||
botName: bot.name,
|
||||
title: bot.name,
|
||||
snippet: bot.title || bot.description || bot.name,
|
||||
},
|
||||
CONVERSATION_HIT_LIMIT * 2,
|
||||
);
|
||||
}
|
||||
|
||||
const groups = await prisma.chatGroup.findMany({
|
||||
where: {
|
||||
spaceId: actor.spaceId,
|
||||
userId: actor.userId,
|
||||
name: { contains: query, mode: "insensitive" },
|
||||
},
|
||||
take: CONVERSATION_HIT_LIMIT,
|
||||
});
|
||||
for (const group of groups) {
|
||||
pushInto(
|
||||
conversationHits,
|
||||
{
|
||||
kind: "conversation",
|
||||
groupId: group.id,
|
||||
groupName: group.name,
|
||||
title: group.name,
|
||||
snippet: group.name,
|
||||
},
|
||||
CONVERSATION_HIT_LIMIT * 2,
|
||||
);
|
||||
}
|
||||
|
||||
const contentBudget = Math.max(0, SEARCH_LIMIT - conversationHits.length);
|
||||
|
||||
const artifacts = await prisma.artifact.findMany({
|
||||
where: {
|
||||
spaceId: actor.spaceId,
|
||||
userId: actor.userId,
|
||||
groupId: null,
|
||||
botId: { not: null },
|
||||
name: { contains: query, mode: "insensitive" },
|
||||
bot: { archivedAt: null },
|
||||
},
|
||||
include: { bot: { select: { name: true } } },
|
||||
take: SEARCH_LIMIT,
|
||||
});
|
||||
for (const artifact of artifacts) {
|
||||
if (!artifact.botId || !artifact.bot) continue;
|
||||
const messageRows = await prisma.$queryRaw<Array<{ id: string; seq: number }>>`
|
||||
SELECT m.id, m.seq
|
||||
FROM messages m
|
||||
INNER JOIN threads t ON t.id = m."threadId"
|
||||
WHERE t."spaceId" = ${actor.spaceId}
|
||||
AND t."userId" = ${actor.userId}
|
||||
AND t."botId" = ${artifact.botId}
|
||||
AND m.blocks::text ILIKE ${`%${artifact.id}%`}
|
||||
ORDER BY m."createdAt" DESC
|
||||
LIMIT 1
|
||||
`;
|
||||
const message = messageRows[0];
|
||||
if (!message) continue;
|
||||
pushInto(
|
||||
contentHits,
|
||||
{
|
||||
kind: "file",
|
||||
botId: artifact.botId,
|
||||
botName: artifact.bot.name,
|
||||
title: artifact.name,
|
||||
snippet: `${artifact.mimeType} · ${artifact.size} bytes`,
|
||||
artifactId: artifact.id,
|
||||
messageId: message.id,
|
||||
seq: message.seq,
|
||||
},
|
||||
contentBudget,
|
||||
);
|
||||
}
|
||||
|
||||
const groupArtifacts = await prisma.artifact.findMany({
|
||||
where: {
|
||||
spaceId: actor.spaceId,
|
||||
userId: actor.userId,
|
||||
groupId: { not: null },
|
||||
name: { contains: query, mode: "insensitive" },
|
||||
},
|
||||
include: { group: { select: { name: true } } },
|
||||
take: SEARCH_LIMIT,
|
||||
});
|
||||
for (const artifact of groupArtifacts) {
|
||||
if (!artifact.groupId || !artifact.group) continue;
|
||||
const messageRows = await prisma.$queryRaw<Array<{ id: string; seq: number }>>`
|
||||
SELECT m.id, m.seq
|
||||
FROM messages m
|
||||
INNER JOIN threads t ON t.id = m."threadId"
|
||||
WHERE t."spaceId" = ${actor.spaceId}
|
||||
AND t."userId" = ${actor.userId}
|
||||
AND t."groupId" = ${artifact.groupId}
|
||||
AND m.blocks::text ILIKE ${`%${artifact.id}%`}
|
||||
ORDER BY m."createdAt" DESC
|
||||
LIMIT 1
|
||||
`;
|
||||
const message = messageRows[0];
|
||||
if (!message) continue;
|
||||
pushInto(
|
||||
contentHits,
|
||||
{
|
||||
kind: "file",
|
||||
groupId: artifact.groupId,
|
||||
groupName: artifact.group.name,
|
||||
title: artifact.name,
|
||||
snippet: `${artifact.mimeType} · ${artifact.size} bytes`,
|
||||
artifactId: artifact.id,
|
||||
messageId: message.id,
|
||||
seq: message.seq,
|
||||
},
|
||||
contentBudget,
|
||||
);
|
||||
}
|
||||
|
||||
const routines = await prisma.routine.findMany({
|
||||
where: {
|
||||
spaceId: actor.spaceId,
|
||||
userId: actor.userId,
|
||||
OR: [
|
||||
{ name: { contains: query, mode: "insensitive" } },
|
||||
{ prompt: { contains: query, mode: "insensitive" } },
|
||||
],
|
||||
bot: { archivedAt: null },
|
||||
},
|
||||
include: { bot: { select: { name: true } } },
|
||||
take: SEARCH_LIMIT,
|
||||
});
|
||||
for (const routine of routines) {
|
||||
pushInto(
|
||||
contentHits,
|
||||
{
|
||||
kind: "routine",
|
||||
botId: routine.botId,
|
||||
botName: routine.bot.name,
|
||||
title: routine.name,
|
||||
snippet: snippetAroundMatch(routine.prompt, query),
|
||||
routineId: routine.id,
|
||||
},
|
||||
contentBudget,
|
||||
);
|
||||
}
|
||||
|
||||
const pattern = `%${query}%`;
|
||||
const messageRows = await prisma.$queryRaw<
|
||||
Array<{
|
||||
id: string;
|
||||
threadId: string;
|
||||
seq: number;
|
||||
blocks: Prisma.JsonValue;
|
||||
botId: string;
|
||||
botName: string;
|
||||
}>
|
||||
>`
|
||||
SELECT m.id, m."threadId", m.seq, m.blocks, b.id AS "botId", b.name AS "botName"
|
||||
FROM messages m
|
||||
INNER JOIN threads t ON t.id = m."threadId"
|
||||
INNER JOIN bots b ON b.id = t."botId"
|
||||
WHERE t."spaceId" = ${actor.spaceId}
|
||||
AND t."userId" = ${actor.userId}
|
||||
AND b."archivedAt" IS NULL
|
||||
AND m.blocks::text ILIKE ${pattern}
|
||||
ORDER BY m."createdAt" DESC
|
||||
LIMIT ${SEARCH_LIMIT}
|
||||
`;
|
||||
|
||||
for (const row of messageRows) {
|
||||
pushMessageHits(row.blocks as MessageBlock[], {
|
||||
botId: row.botId,
|
||||
botName: row.botName,
|
||||
messageId: row.id,
|
||||
seq: row.seq,
|
||||
query,
|
||||
push: (hit) => pushInto(contentHits, hit, contentBudget),
|
||||
});
|
||||
}
|
||||
|
||||
const groupMessageRows = await prisma.$queryRaw<
|
||||
Array<{
|
||||
id: string;
|
||||
threadId: string;
|
||||
seq: number;
|
||||
blocks: Prisma.JsonValue;
|
||||
groupId: string;
|
||||
groupName: string;
|
||||
}>
|
||||
>`
|
||||
SELECT m.id, m."threadId", m.seq, m.blocks, g.id AS "groupId", g.name AS "groupName"
|
||||
FROM messages m
|
||||
INNER JOIN threads t ON t.id = m."threadId"
|
||||
INNER JOIN chat_groups g ON g.id = t."groupId"
|
||||
WHERE t."spaceId" = ${actor.spaceId}
|
||||
AND t."userId" = ${actor.userId}
|
||||
AND t."groupId" IS NOT NULL
|
||||
AND m.blocks::text ILIKE ${pattern}
|
||||
ORDER BY m."createdAt" DESC
|
||||
LIMIT ${SEARCH_LIMIT}
|
||||
`;
|
||||
|
||||
for (const row of groupMessageRows) {
|
||||
pushMessageHits(row.blocks as MessageBlock[], {
|
||||
groupId: row.groupId,
|
||||
groupName: row.groupName,
|
||||
messageId: row.id,
|
||||
seq: row.seq,
|
||||
query,
|
||||
push: (hit) => pushInto(contentHits, hit, contentBudget),
|
||||
});
|
||||
}
|
||||
|
||||
return [...conversationHits, ...contentHits].slice(0, SEARCH_LIMIT);
|
||||
}
|
||||
|
||||
function pushMessageHits(
|
||||
blocks: MessageBlock[],
|
||||
ctx: {
|
||||
botId?: string;
|
||||
botName?: string;
|
||||
groupId?: string;
|
||||
groupName?: string;
|
||||
messageId: string;
|
||||
seq: number;
|
||||
query: string;
|
||||
push: (hit: SearchHit) => void;
|
||||
},
|
||||
) {
|
||||
// Discriminate on groupId (not name): empty group names must still target the group.
|
||||
const destination = ctx.groupId
|
||||
? { groupId: ctx.groupId, groupName: ctx.groupName ?? "" }
|
||||
: { botId: ctx.botId!, botName: ctx.botName! };
|
||||
const title = ctx.groupId ? (ctx.groupName ?? "") : (ctx.botName ?? "");
|
||||
let messageHit = false;
|
||||
for (const block of blocks) {
|
||||
if (block.kind !== "text") continue;
|
||||
const text = block.text;
|
||||
if (matchesSearchQuery(ctx.query, text)) {
|
||||
ctx.push({
|
||||
kind: "message",
|
||||
...destination,
|
||||
title,
|
||||
snippet: snippetAroundMatch(text, ctx.query),
|
||||
messageId: ctx.messageId,
|
||||
seq: ctx.seq,
|
||||
});
|
||||
messageHit = true;
|
||||
}
|
||||
for (const url of extractLinksFromText(text)) {
|
||||
if (matchesSearchQuery(ctx.query, url)) {
|
||||
ctx.push({
|
||||
kind: "link",
|
||||
...destination,
|
||||
title: url,
|
||||
snippet: snippetAroundMatch(text, ctx.query),
|
||||
messageId: ctx.messageId,
|
||||
seq: ctx.seq,
|
||||
url,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!messageHit && matchesSearchQuery(ctx.query, JSON.stringify(blocks))) {
|
||||
ctx.push({
|
||||
kind: "message",
|
||||
...destination,
|
||||
title,
|
||||
snippet: ctx.query,
|
||||
messageId: ctx.messageId,
|
||||
seq: ctx.seq,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,63 @@
|
|||
import { describe, expect, it, vi } from "vitest";
|
||||
import { withSerializableRetry } from "./serializable-retry.js";
|
||||
|
||||
function serializationConflict() {
|
||||
return Object.assign(new Error("serialization conflict"), { code: "P2034" });
|
||||
}
|
||||
|
||||
function adapterConflict(originalCode: "40001" | "40P01") {
|
||||
return Object.assign(new Error("database conflict"), {
|
||||
code: "P2039",
|
||||
meta: { driverAdapterError: { cause: { originalCode } } },
|
||||
});
|
||||
}
|
||||
|
||||
describe("withSerializableRetry", () => {
|
||||
it("retries serialization conflicts and returns the successful result", async () => {
|
||||
const operation = vi
|
||||
.fn<() => Promise<string>>()
|
||||
.mockRejectedValueOnce(serializationConflict())
|
||||
.mockRejectedValueOnce(serializationConflict())
|
||||
.mockResolvedValue("ok");
|
||||
|
||||
await expect(withSerializableRetry(operation)).resolves.toBe("ok");
|
||||
expect(operation).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
|
||||
it.each(["40001", "40P01"] as const)(
|
||||
"retries driver-adapter transaction conflict %s",
|
||||
async (databaseCode) => {
|
||||
const operation = vi
|
||||
.fn<() => Promise<string>>()
|
||||
.mockRejectedValueOnce(adapterConflict(databaseCode))
|
||||
.mockResolvedValue("ok");
|
||||
|
||||
await expect(withSerializableRetry(operation)).resolves.toBe("ok");
|
||||
expect(operation).toHaveBeenCalledTimes(2);
|
||||
},
|
||||
);
|
||||
|
||||
it("rethrows non-serialization errors without retrying", async () => {
|
||||
const error = new Error("unrelated failure");
|
||||
const operation = vi.fn<() => Promise<never>>().mockRejectedValue(error);
|
||||
|
||||
await expect(withSerializableRetry(operation)).rejects.toBe(error);
|
||||
expect(operation).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("does not retry unrelated driver-adapter errors", async () => {
|
||||
const error = Object.assign(new Error("external connector error"), { code: "P2039" });
|
||||
const operation = vi.fn<() => Promise<never>>().mockRejectedValue(error);
|
||||
|
||||
await expect(withSerializableRetry(operation)).rejects.toBe(error);
|
||||
expect(operation).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("rethrows the final serialization conflict after the bounded retry limit", async () => {
|
||||
const error = serializationConflict();
|
||||
const operation = vi.fn<() => Promise<never>>().mockRejectedValue(error);
|
||||
|
||||
await expect(withSerializableRetry(operation)).rejects.toBe(error);
|
||||
expect(operation).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1 @@
|
|||
export { withTransactionRetry as withSerializableRetry } from "@rakazo/db";
|
||||
|
|
@ -0,0 +1,274 @@
|
|||
import { mkdir, mkdtemp, rm } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
applyServerUpdate,
|
||||
assertNoGitApplyPath,
|
||||
checkServerUpdate,
|
||||
isUpdaterConfigured,
|
||||
readServerUpdateStatus,
|
||||
type UpdaterProxyConfig,
|
||||
UpdaterProxyError,
|
||||
} from "./server-update.js";
|
||||
|
||||
const TOKEN = "fake-review-updater-token-000000000000";
|
||||
const URL = "http://updater:7092";
|
||||
const roots: string[] = [];
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true })));
|
||||
});
|
||||
|
||||
describe("server update install kind", () => {
|
||||
async function tempRoot(withGit: boolean) {
|
||||
const root = await mkdtemp(path.join(tmpdir(), "rakazo-update-"));
|
||||
roots.push(root);
|
||||
if (withGit) await mkdir(path.join(root, ".git"));
|
||||
return root;
|
||||
}
|
||||
|
||||
it("detects a source checkout when the sidecar is not wired", async () => {
|
||||
const root = await tempRoot(true);
|
||||
const status = await readServerUpdateStatus({
|
||||
url: null,
|
||||
token: null,
|
||||
gitSha: "abc",
|
||||
checkoutRoot: root,
|
||||
fetch: vi.fn(),
|
||||
});
|
||||
expect(status.installKind).toBe("source");
|
||||
expect(status.supported).toBe(false);
|
||||
expect(status.manualCommands.some((line) => line.includes("git pull"))).toBe(true);
|
||||
expect(status.manualCommands.some((line) => line.includes("migrate"))).toBe(true);
|
||||
});
|
||||
|
||||
it("detects compose-without-sidecar when the updater URL is set but unreachable", async () => {
|
||||
const root = await tempRoot(false);
|
||||
const status = await readServerUpdateStatus({
|
||||
url: URL,
|
||||
token: TOKEN,
|
||||
gitSha: "abc",
|
||||
imageTag: "sha-aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
|
||||
checkoutRoot: root,
|
||||
fetch: vi.fn(async () => {
|
||||
throw new Error("ECONNREFUSED");
|
||||
}),
|
||||
});
|
||||
expect(status.installKind).toBe("compose");
|
||||
expect(status.supported).toBe(false);
|
||||
expect(status.manualCommands[0]).toMatch(/docker compose .*pull api worker web/);
|
||||
expect(status.manualCommands[1]).toMatch(/up -d --wait --pull never/);
|
||||
});
|
||||
|
||||
it("shows rebuild commands for a compose install on the local image tag", async () => {
|
||||
const root = await tempRoot(false);
|
||||
const status = await readServerUpdateStatus({
|
||||
url: URL,
|
||||
token: TOKEN,
|
||||
gitSha: "abc",
|
||||
imageTag: "local",
|
||||
checkoutRoot: root,
|
||||
fetch: vi.fn(async () => {
|
||||
throw new Error("ECONNREFUSED");
|
||||
}),
|
||||
});
|
||||
expect(status.installKind).toBe("compose");
|
||||
expect(status.manualCommands.some((line) => line.includes("--build"))).toBe(true);
|
||||
expect(status.manualCommands.some((line) => line.includes("git pull"))).toBe(true);
|
||||
expect(status.manualCommands.some((line) => /\bpull api worker web\b/.test(line))).toBe(false);
|
||||
});
|
||||
|
||||
it("detects sidecar when health and authenticated state succeed", async () => {
|
||||
const root = await tempRoot(false);
|
||||
const fetchImpl = vi.fn(async (input: RequestInfo | URL) => {
|
||||
const href = String(input);
|
||||
if (href.endsWith("/health")) {
|
||||
return new Response(JSON.stringify({ ok: true }), { status: 200 });
|
||||
}
|
||||
if (href.endsWith("/state")) {
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
image: "ghcr.io/elie222/rakazo/app",
|
||||
currentTag: "sha-aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
|
||||
previousTag: "sha-bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
|
||||
running: false,
|
||||
checkout: {
|
||||
present: true,
|
||||
commit: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
|
||||
branch: "main",
|
||||
remoteUrl: "https://github.com/elie222/rakazo",
|
||||
dirty: false,
|
||||
dirtyPaths: [],
|
||||
},
|
||||
}),
|
||||
{ status: 200 },
|
||||
);
|
||||
}
|
||||
return new Response("missing", { status: 404 });
|
||||
});
|
||||
const status = await readServerUpdateStatus({
|
||||
url: URL,
|
||||
token: TOKEN,
|
||||
gitSha: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
|
||||
checkoutRoot: root,
|
||||
fetch: fetchImpl as unknown as typeof fetch,
|
||||
});
|
||||
expect(status.installKind).toBe("sidecar");
|
||||
expect(status.supported).toBe(true);
|
||||
expect(status.canRollback).toBe(true);
|
||||
expect(status.manualCommands).toEqual([]);
|
||||
expect(status.imageTag).toMatch(/^sha-/);
|
||||
expect(status.lastRun).toBeNull();
|
||||
});
|
||||
|
||||
it("surfaces a finished sidecar lastRun for recreate confirmation", async () => {
|
||||
const root = await tempRoot(false);
|
||||
const lastRun = {
|
||||
startedAt: "2026-08-27T21:00:00.000Z",
|
||||
finishedAt: "2026-08-27T21:01:00.000Z",
|
||||
ok: false,
|
||||
fromCommit: null,
|
||||
toCommit: null,
|
||||
fromTag: "sha-aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
|
||||
toTag: "sha-bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
|
||||
strategy: "pull",
|
||||
repoUrl: "https://github.com/elie222/rakazo",
|
||||
branch: "main",
|
||||
restart: "not-required",
|
||||
restartAdvice: "Recreate failed; prior image restored, env pin not restored.",
|
||||
error: "Recreate the stack failed.",
|
||||
steps: [],
|
||||
};
|
||||
const fetchImpl = vi.fn(async (input: RequestInfo | URL) => {
|
||||
const href = String(input);
|
||||
if (href.endsWith("/health")) {
|
||||
return new Response(JSON.stringify({ ok: true }), { status: 200 });
|
||||
}
|
||||
if (href.endsWith("/state")) {
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
image: "ghcr.io/elie222/rakazo/app",
|
||||
currentTag: "sha-bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
|
||||
previousTag: "sha-aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
|
||||
running: false,
|
||||
lastRun,
|
||||
checkout: {
|
||||
present: true,
|
||||
commit: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
|
||||
branch: "main",
|
||||
remoteUrl: "https://github.com/elie222/rakazo",
|
||||
dirty: false,
|
||||
dirtyPaths: [],
|
||||
},
|
||||
}),
|
||||
{ status: 200 },
|
||||
);
|
||||
}
|
||||
return new Response("missing", { status: 404 });
|
||||
});
|
||||
const status = await readServerUpdateStatus({
|
||||
url: URL,
|
||||
token: TOKEN,
|
||||
gitSha: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
|
||||
checkoutRoot: root,
|
||||
fetch: fetchImpl as unknown as typeof fetch,
|
||||
});
|
||||
expect(status.lastRun).toMatchObject({
|
||||
ok: false,
|
||||
fromTag: "sha-aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
|
||||
toTag: "sha-bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
|
||||
error: "Recreate the stack failed.",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("sidecar proxy auth and no-git-apply", () => {
|
||||
it("requires both URL and token before claiming the sidecar is configured", () => {
|
||||
expect(isUpdaterConfigured({ url: URL, token: null, gitSha: undefined })).toBe(false);
|
||||
expect(isUpdaterConfigured({ url: null, token: TOKEN, gitSha: undefined })).toBe(false);
|
||||
expect(isUpdaterConfigured({ url: URL, token: TOKEN, gitSha: undefined })).toBe(true);
|
||||
});
|
||||
|
||||
it("sends the bearer token to the sidecar and never returns it", async () => {
|
||||
const authHeaders: string[] = [];
|
||||
const fetchImpl = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
|
||||
const href = String(input);
|
||||
const headers = new Headers(init?.headers);
|
||||
const authorization = headers.get("authorization");
|
||||
if (authorization) authHeaders.push(authorization);
|
||||
if (href.endsWith("/health")) {
|
||||
return new Response(JSON.stringify({ ok: true }), { status: 200 });
|
||||
}
|
||||
if (href.endsWith("/state")) {
|
||||
return new Response(JSON.stringify({ running: false, currentTag: "local" }), {
|
||||
status: 200,
|
||||
});
|
||||
}
|
||||
if (href.endsWith("/plan")) {
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
upToDate: false,
|
||||
targetCommit: "cccccccccccccccccccccccccccccccccccccccc",
|
||||
targetTag: "sha-cccccccccccccccccccccccccccccccccccccccc",
|
||||
checkout: { commit: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", dirty: false },
|
||||
}),
|
||||
{ status: 200 },
|
||||
);
|
||||
}
|
||||
return new Response(JSON.stringify({ error: "unexpected" }), { status: 500 });
|
||||
});
|
||||
const root = await mkdtemp(path.join(tmpdir(), "rakazo-proxy-"));
|
||||
roots.push(root);
|
||||
const config: UpdaterProxyConfig = {
|
||||
url: URL,
|
||||
token: TOKEN,
|
||||
gitSha: undefined,
|
||||
checkoutRoot: root,
|
||||
fetch: fetchImpl as unknown as typeof fetch,
|
||||
};
|
||||
const check = await checkServerUpdate(config);
|
||||
expect(check.status).toBe("available");
|
||||
expect(authHeaders).toContain(`Bearer ${TOKEN}`);
|
||||
expect(JSON.stringify(check)).not.toContain(TOKEN);
|
||||
});
|
||||
|
||||
it("rejects check/apply when the sidecar is off (no git apply path)", async () => {
|
||||
const config: UpdaterProxyConfig = {
|
||||
url: null,
|
||||
token: null,
|
||||
gitSha: undefined,
|
||||
fetch: vi.fn(),
|
||||
};
|
||||
await expect(checkServerUpdate(config)).rejects.toBeInstanceOf(UpdaterProxyError);
|
||||
await expect(applyServerUpdate(config)).rejects.toMatchObject({
|
||||
message: expect.stringMatching(/sidecar/i),
|
||||
});
|
||||
expect(assertNoGitApplyPath).toBeTypeOf("function");
|
||||
expect(() => assertNoGitApplyPath("source")).toThrow(/cannot apply/);
|
||||
expect(() => assertNoGitApplyPath("compose")).toThrow(/cannot apply/);
|
||||
expect(() => assertNoGitApplyPath("sidecar")).not.toThrow();
|
||||
});
|
||||
|
||||
it("maps a 401 from the sidecar without exposing the token", async () => {
|
||||
const fetchImpl = vi.fn(async (input: RequestInfo | URL) => {
|
||||
const href = String(input);
|
||||
if (href.endsWith("/health")) {
|
||||
return new Response(JSON.stringify({ ok: true }), { status: 200 });
|
||||
}
|
||||
return new Response(JSON.stringify({ error: "unauthorized" }), { status: 401 });
|
||||
});
|
||||
const config: UpdaterProxyConfig = {
|
||||
url: URL,
|
||||
token: TOKEN,
|
||||
gitSha: undefined,
|
||||
fetch: fetchImpl as unknown as typeof fetch,
|
||||
};
|
||||
await expect(applyServerUpdate(config)).rejects.toMatchObject({
|
||||
message: expect.stringMatching(/credential|reachable|sidecar/i),
|
||||
});
|
||||
await expect(applyServerUpdate(config)).rejects.not.toMatchObject({
|
||||
message: expect.stringContaining(TOKEN),
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,353 @@
|
|||
import { access } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import type {
|
||||
ServerUpdateCheck,
|
||||
ServerUpdateRequest,
|
||||
ServerUpdateRun,
|
||||
ServerUpdateStatus,
|
||||
} from "@rakazo/contracts";
|
||||
import { ServerUpdateRunSchema } from "@rakazo/contracts";
|
||||
import {
|
||||
DEFAULT_UPDATE_BRANCH,
|
||||
detectRestartSupervisor,
|
||||
isOfficialRepoUrl,
|
||||
manualUpgradeCommands,
|
||||
OFFICIAL_REPO_URL,
|
||||
OFFICIAL_SERVER_IMAGE,
|
||||
resolveInstallKind,
|
||||
restartSupervisorAdvice,
|
||||
} from "@rakazo/core";
|
||||
|
||||
const PRODUCT_VERSION = "0.1.0";
|
||||
const STATE_TIMEOUT_MS = 15_000;
|
||||
const PLAN_TIMEOUT_MS = 180_000;
|
||||
const APPLY_TIMEOUT_MS = 2_100_000;
|
||||
|
||||
export interface UpdaterProxyConfig {
|
||||
url: string | null;
|
||||
token: string | null;
|
||||
gitSha: string | undefined;
|
||||
/** Current `RAKAZO_IMAGE_TAG` when known; selects compose pull vs rebuild commands. */
|
||||
imageTag?: string | null;
|
||||
disabled?: boolean;
|
||||
/** Override for tests; defaults to process.cwd(). */
|
||||
checkoutRoot?: string;
|
||||
fetch?: typeof fetch;
|
||||
}
|
||||
|
||||
export class UpdaterProxyError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
readonly status: number = 400,
|
||||
) {
|
||||
super(message);
|
||||
this.name = "UpdaterProxyError";
|
||||
}
|
||||
}
|
||||
|
||||
/** True only when the API can authenticate to the sidecar. The token never leaves this process. */
|
||||
export function isUpdaterConfigured(config: UpdaterProxyConfig): boolean {
|
||||
return Boolean(config.url?.trim() && config.token?.trim());
|
||||
}
|
||||
|
||||
export async function hasGitCheckout(root: string): Promise<boolean> {
|
||||
try {
|
||||
await access(path.join(root, ".git"));
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function probeSidecar(config: UpdaterProxyConfig, fetchImpl: typeof fetch): Promise<boolean> {
|
||||
if (!isUpdaterConfigured(config) || !config.url || !config.token) return false;
|
||||
try {
|
||||
const response = await fetchImpl(new URL("/health", ensureTrailingSlash(config.url)), {
|
||||
method: "GET",
|
||||
signal: AbortSignal.timeout(5_000),
|
||||
});
|
||||
if (!response.ok) return false;
|
||||
// Confirm the bearer works: /health is open, so a wrong token would still look "up".
|
||||
const state = await fetchImpl(new URL("/state", ensureTrailingSlash(config.url)), {
|
||||
method: "GET",
|
||||
headers: { authorization: `Bearer ${config.token}` },
|
||||
signal: AbortSignal.timeout(5_000),
|
||||
});
|
||||
return state.ok;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function ensureTrailingSlash(url: string): string {
|
||||
return url.endsWith("/") ? url : `${url}/`;
|
||||
}
|
||||
|
||||
export async function readServerUpdateStatus(
|
||||
config: UpdaterProxyConfig,
|
||||
): Promise<ServerUpdateStatus> {
|
||||
const fetchImpl = config.fetch ?? fetch;
|
||||
const checkoutRoot = config.checkoutRoot ?? process.cwd();
|
||||
const hasCheckout = await hasGitCheckout(checkoutRoot);
|
||||
const urlConfigured = Boolean(config.url?.trim());
|
||||
const reachable = await probeSidecar(config, fetchImpl);
|
||||
const install = resolveInstallKind({
|
||||
updaterUrlConfigured: urlConfigured,
|
||||
updaterReachable: reachable,
|
||||
hasCheckout,
|
||||
disabled: config.disabled === true,
|
||||
});
|
||||
const supervisor = detectRestartSupervisor(process.env);
|
||||
const imageTagHint = config.imageTag?.trim() || process.env.RAKAZO_IMAGE_TAG?.trim() || null;
|
||||
const base: ServerUpdateStatus = {
|
||||
supported: install.kind === "sidecar",
|
||||
unsupportedReason: install.kind === "sidecar" ? null : install.reason,
|
||||
installKind: install.kind,
|
||||
manualCommands: [...manualUpgradeCommands(install.kind, { imageTag: imageTagHint })],
|
||||
mode: install.mode,
|
||||
strategy: null,
|
||||
strategyNote: null,
|
||||
version: PRODUCT_VERSION,
|
||||
revision: config.gitSha ?? null,
|
||||
commit: config.gitSha ?? null,
|
||||
branch: null,
|
||||
remoteUrl: null,
|
||||
dirty: false,
|
||||
dirtyPaths: [],
|
||||
image: null,
|
||||
imageTag: imageTagHint,
|
||||
previousImageTag: null,
|
||||
canRollback: false,
|
||||
source: {
|
||||
repoUrl: OFFICIAL_REPO_URL,
|
||||
branch: DEFAULT_UPDATE_BRANCH,
|
||||
official: true,
|
||||
},
|
||||
officialRepoUrl: OFFICIAL_REPO_URL,
|
||||
restartSupervisor: supervisor.kind,
|
||||
restartAdvice: restartSupervisorAdvice(supervisor),
|
||||
running: false,
|
||||
lastRun: null,
|
||||
};
|
||||
|
||||
if (install.kind !== "sidecar" || !config.url || !config.token) return base;
|
||||
|
||||
try {
|
||||
const state = await sidecarJson<{
|
||||
image?: string;
|
||||
imageRef?: string;
|
||||
currentTag?: string;
|
||||
previousTag?: string | null;
|
||||
running?: boolean;
|
||||
lastRun?: unknown;
|
||||
checkout?: {
|
||||
present?: boolean;
|
||||
commit?: string | null;
|
||||
branch?: string | null;
|
||||
remoteUrl?: string | null;
|
||||
dirty?: boolean;
|
||||
dirtyPaths?: string[];
|
||||
};
|
||||
}>(config, fetchImpl, "GET", "/state", undefined, STATE_TIMEOUT_MS);
|
||||
|
||||
const remoteUrl = state.checkout?.remoteUrl ?? null;
|
||||
const official = remoteUrl ? isOfficialRepoUrl(remoteUrl) : true;
|
||||
const parsedLastRun = ServerUpdateRunSchema.safeParse(state.lastRun);
|
||||
return {
|
||||
...base,
|
||||
supported: true,
|
||||
unsupportedReason: null,
|
||||
strategy: official ? "pull" : "build",
|
||||
strategyNote: official
|
||||
? "Official releases pull the published image."
|
||||
: "This fork builds on the server.",
|
||||
commit: state.checkout?.commit ?? base.commit,
|
||||
branch: state.checkout?.branch ?? null,
|
||||
remoteUrl,
|
||||
dirty: state.checkout?.dirty === true,
|
||||
dirtyPaths: state.checkout?.dirtyPaths ?? [],
|
||||
image: state.image ?? OFFICIAL_SERVER_IMAGE,
|
||||
imageTag: state.currentTag ?? null,
|
||||
previousImageTag: state.previousTag ?? null,
|
||||
canRollback: Boolean(state.previousTag),
|
||||
source: {
|
||||
repoUrl:
|
||||
remoteUrl && isOfficialRepoUrl(remoteUrl)
|
||||
? OFFICIAL_REPO_URL
|
||||
: (remoteUrl ?? OFFICIAL_REPO_URL),
|
||||
branch: state.checkout?.branch ?? DEFAULT_UPDATE_BRANCH,
|
||||
official,
|
||||
},
|
||||
running: state.running === true,
|
||||
lastRun: parsedLastRun.success ? parsedLastRun.data : null,
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
...base,
|
||||
supported: false,
|
||||
unsupportedReason:
|
||||
error instanceof Error ? error.message : "The updater sidecar did not respond.",
|
||||
installKind: "compose",
|
||||
mode: "unavailable",
|
||||
manualCommands: [
|
||||
...manualUpgradeCommands("compose", {
|
||||
imageTag: imageTagHint,
|
||||
}),
|
||||
],
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export async function checkServerUpdate(
|
||||
config: UpdaterProxyConfig,
|
||||
input: ServerUpdateRequest = {},
|
||||
): Promise<ServerUpdateCheck> {
|
||||
await requireSidecar(config);
|
||||
const fetchImpl = config.fetch ?? fetch;
|
||||
const plan = await sidecarJson<{
|
||||
upToDate?: boolean;
|
||||
reason?: string;
|
||||
targetCommit?: string | null;
|
||||
targetTag?: string | null;
|
||||
checkout?: { commit?: string | null; dirty?: boolean; dirtyPaths?: string[] };
|
||||
}>(config, fetchImpl, "POST", "/plan", requestBody(input), PLAN_TIMEOUT_MS);
|
||||
|
||||
if (plan.checkout?.dirty === true) {
|
||||
return {
|
||||
status: "dirty",
|
||||
reason: "The deployment checkout has local changes.",
|
||||
changed: plan.checkout.dirtyPaths ?? [],
|
||||
commit: plan.checkout.commit ?? null,
|
||||
targetCommit: plan.targetCommit ?? null,
|
||||
targetTag: plan.targetTag ?? null,
|
||||
behindBy: 0,
|
||||
};
|
||||
}
|
||||
if (plan.upToDate === true) {
|
||||
return {
|
||||
status: "up-to-date",
|
||||
reason: plan.reason ?? null,
|
||||
changed: [],
|
||||
commit: plan.checkout?.commit ?? null,
|
||||
targetCommit: plan.targetCommit ?? null,
|
||||
targetTag: plan.targetTag ?? null,
|
||||
behindBy: 0,
|
||||
};
|
||||
}
|
||||
return {
|
||||
status: "available",
|
||||
reason: plan.reason ?? null,
|
||||
changed: [],
|
||||
commit: plan.checkout?.commit ?? null,
|
||||
targetCommit: plan.targetCommit ?? null,
|
||||
targetTag: plan.targetTag ?? null,
|
||||
behindBy:
|
||||
plan.targetCommit && plan.checkout?.commit && plan.targetCommit !== plan.checkout.commit
|
||||
? 1
|
||||
: 0,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Proxies `/apply` to the sidecar.
|
||||
*
|
||||
* A successful recreate replaces this API container while the request is still open, so the
|
||||
* JSON body often never reaches the browser. Clients must treat a mid-flight transport failure
|
||||
* as "recreate in progress" and re-fetch `status` once the API is healthy again.
|
||||
*/
|
||||
export async function applyServerUpdate(
|
||||
config: UpdaterProxyConfig,
|
||||
input: ServerUpdateRequest = {},
|
||||
): Promise<ServerUpdateRun> {
|
||||
await requireSidecar(config);
|
||||
const fetchImpl = config.fetch ?? fetch;
|
||||
return sidecarJson<ServerUpdateRun>(
|
||||
config,
|
||||
fetchImpl,
|
||||
"POST",
|
||||
"/apply",
|
||||
requestBody(input),
|
||||
APPLY_TIMEOUT_MS,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Hard gate: Settings apply/check never run git (or anything else) inside the API.
|
||||
* Sidecar `/rollback` remains for ops only and is not exposed on the owner RPC surface.
|
||||
* Only the updater sidecar holds the Docker socket and outlives a recreate.
|
||||
*/
|
||||
async function requireSidecar(config: UpdaterProxyConfig): Promise<void> {
|
||||
if (config.disabled === true) {
|
||||
throw new UpdaterProxyError("Self-update is switched off for this deployment.");
|
||||
}
|
||||
if (!isUpdaterConfigured(config)) {
|
||||
throw new UpdaterProxyError(
|
||||
"The updater sidecar is not configured. Use the host Compose commands, or enable the updater profile.",
|
||||
);
|
||||
}
|
||||
const fetchImpl = config.fetch ?? fetch;
|
||||
if (!(await probeSidecar(config, fetchImpl))) {
|
||||
throw new UpdaterProxyError(
|
||||
"The updater sidecar is not reachable. Start the opt-in updater profile, or upgrade from the host.",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function requestBody(input: ServerUpdateRequest): Record<string, string> {
|
||||
const body: Record<string, string> = {};
|
||||
if (input.repoUrl?.trim()) body.repoUrl = input.repoUrl.trim();
|
||||
if (input.branch?.trim()) body.branch = input.branch.trim();
|
||||
return body;
|
||||
}
|
||||
|
||||
async function sidecarJson<T>(
|
||||
config: UpdaterProxyConfig,
|
||||
fetchImpl: typeof fetch,
|
||||
method: string,
|
||||
route: string,
|
||||
body: unknown | undefined,
|
||||
timeoutMs: number,
|
||||
): Promise<T> {
|
||||
if (!config.url || !config.token) {
|
||||
throw new UpdaterProxyError("The updater sidecar is not configured.");
|
||||
}
|
||||
let response: Response;
|
||||
try {
|
||||
response = await fetchImpl(new URL(route.replace(/^\//, ""), ensureTrailingSlash(config.url)), {
|
||||
method,
|
||||
headers: {
|
||||
authorization: `Bearer ${config.token}`,
|
||||
...(body === undefined ? {} : { "content-type": "application/json" }),
|
||||
},
|
||||
body: body === undefined ? undefined : JSON.stringify(body),
|
||||
signal: AbortSignal.timeout(timeoutMs),
|
||||
});
|
||||
} catch (error) {
|
||||
throw new UpdaterProxyError(
|
||||
error instanceof Error ? error.message : "The updater sidecar did not respond.",
|
||||
502,
|
||||
);
|
||||
}
|
||||
const payload = (await response.json().catch(() => ({}))) as T & { error?: string };
|
||||
if (response.status === 401) {
|
||||
throw new UpdaterProxyError("The updater sidecar rejected the deployment credential.", 502);
|
||||
}
|
||||
if (!response.ok) {
|
||||
throw new UpdaterProxyError(
|
||||
typeof payload.error === "string" && payload.error
|
||||
? payload.error
|
||||
: `Updater sidecar returned ${response.status}.`,
|
||||
response.status >= 400 && response.status < 500 ? 400 : 502,
|
||||
);
|
||||
}
|
||||
return payload;
|
||||
}
|
||||
|
||||
/** Exported for tests that assert the API never treats a source tree as applyable. */
|
||||
export function assertNoGitApplyPath(installKind: string): void {
|
||||
if (installKind === "source" || installKind === "compose") {
|
||||
throw new UpdaterProxyError(
|
||||
"Settings cannot apply updates without the updater sidecar. Use the documented host commands.",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,551 @@
|
|||
import { randomUUID } from "node:crypto";
|
||||
import { ORPCError } from "@orpc/server";
|
||||
import type { AgentHomeStore, JobPublisher, SandboxProvider } from "@rakazo/adapter-kit";
|
||||
import {
|
||||
type AdapterContext,
|
||||
runContinueJob,
|
||||
skillTeachingExpireJob,
|
||||
skillTeachingExpireJobKey,
|
||||
} from "@rakazo/adapter-kit";
|
||||
import {
|
||||
acquireComputerExecutionLease,
|
||||
appendRecordingEvent,
|
||||
captureTeachingSnapshot,
|
||||
completeTeachingSession,
|
||||
emptyRecording,
|
||||
expireTaughtSkillTeaching,
|
||||
extendActiveComputerControl,
|
||||
getActiveTeachingSession,
|
||||
mapTaughtSkill,
|
||||
observeStopSnapshot,
|
||||
parsePlaybook,
|
||||
parseRecording,
|
||||
provisionComputer,
|
||||
recordTeachingInputEvent,
|
||||
releaseComputerExecutionLease,
|
||||
releaseTeachingComputerControlForBot,
|
||||
scheduleComputerControlExpiry,
|
||||
screenLeaseIdForRun,
|
||||
type TeachComputerInput,
|
||||
teachingControlLeaseExpiresAt,
|
||||
} from "@rakazo/adapters";
|
||||
import type { Actor, MessageBlock, TaughtSkill } from "@rakazo/contracts";
|
||||
import {
|
||||
ACTIVE_RUN_STATUSES,
|
||||
buildPlaybookFromRecording,
|
||||
formatSkillRunPrompt,
|
||||
type SkillPlaybook,
|
||||
type TeachRecordingEvent,
|
||||
teachRecordingTtlMs,
|
||||
} from "@rakazo/core";
|
||||
import { IsolationError, type PrismaClient, type ThreadEvents } from "@rakazo/db";
|
||||
|
||||
type TaughtSkillRow = {
|
||||
id: string;
|
||||
spaceId: string;
|
||||
botId: string;
|
||||
userId: string;
|
||||
name: string;
|
||||
goal: string;
|
||||
status: string;
|
||||
playbook: unknown;
|
||||
recording: unknown;
|
||||
startedAt: Date | null;
|
||||
expiresAt: Date | null;
|
||||
stoppedAt: Date | null;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
};
|
||||
|
||||
export interface TaughtSkillsDeps {
|
||||
prisma: PrismaClient;
|
||||
events: ThreadEvents;
|
||||
jobs: JobPublisher;
|
||||
sandbox: SandboxProvider;
|
||||
home: AgentHomeStore;
|
||||
dataDir: string;
|
||||
}
|
||||
|
||||
function computerContext(actor: Actor, botId: string, operationId: string): AdapterContext {
|
||||
return {
|
||||
operationId,
|
||||
traceId: operationId,
|
||||
spaceId: actor.spaceId,
|
||||
userId: actor.userId,
|
||||
botId,
|
||||
signal: new AbortController().signal,
|
||||
};
|
||||
}
|
||||
|
||||
function ownedSkillWhere(actor: Actor, skillId: string) {
|
||||
return { id: skillId, spaceId: actor.spaceId, userId: actor.userId };
|
||||
}
|
||||
|
||||
async function getOwnedSkill(
|
||||
deps: TaughtSkillsDeps,
|
||||
actor: Actor,
|
||||
skillId: string,
|
||||
): Promise<TaughtSkillRow> {
|
||||
const skill = await deps.prisma.taughtSkill.findFirst({ where: ownedSkillWhere(actor, skillId) });
|
||||
if (!skill) throw new IsolationError();
|
||||
return skill;
|
||||
}
|
||||
|
||||
export async function assertTeachingSendAllowed(
|
||||
prisma: PrismaClient,
|
||||
spaceId: string,
|
||||
botId: string,
|
||||
): Promise<void> {
|
||||
const active = await getActiveTeachingSession(prisma, spaceId, botId);
|
||||
if (active) {
|
||||
throw new ORPCError("CONFLICT", { message: "Stop teaching first" });
|
||||
}
|
||||
}
|
||||
|
||||
async function cancelActiveRuns(
|
||||
deps: TaughtSkillsDeps,
|
||||
_actor: Actor,
|
||||
botId: string,
|
||||
): Promise<void> {
|
||||
const activeRuns = await deps.prisma.run.findMany({
|
||||
where: { botId, status: { in: [...ACTIVE_RUN_STATUSES] } },
|
||||
select: { id: true },
|
||||
});
|
||||
await deps.prisma.run.updateMany({
|
||||
where: { botId, status: { in: [...ACTIVE_RUN_STATUSES] } },
|
||||
data: { status: "cancelled", completedAt: new Date() },
|
||||
});
|
||||
await deps.prisma.computerExecutionLease.deleteMany({ where: { botId } });
|
||||
await deps.prisma.computer.updateMany({
|
||||
where: { executionBotId: botId },
|
||||
data: { executionRunId: null, executionBotId: null, executionLeaseExpiresAt: null },
|
||||
});
|
||||
await deps.prisma.event.deleteMany({
|
||||
where: { type: "thread.progress", runId: { in: activeRuns.map((run) => run.id) } },
|
||||
});
|
||||
}
|
||||
|
||||
async function ensureGraphicalComputer(
|
||||
deps: TaughtSkillsDeps,
|
||||
actor: Actor,
|
||||
bot: Awaited<ReturnType<ReturnType<typeof import("@rakazo/db").createRepos>["getBot"]>>,
|
||||
) {
|
||||
if (bot.computer?.kind === "desktop") {
|
||||
throw new ORPCError("BAD_REQUEST", {
|
||||
message: "Teaching needs a graphical sandbox computer, not a desktop host",
|
||||
});
|
||||
}
|
||||
if (!bot.computer) throw new IsolationError();
|
||||
if (bot.computer.state !== "running" || !bot.computer.providerRef) {
|
||||
const ctx = computerContext(actor, bot.id, "skills.start");
|
||||
const manualRunId = `teach:${randomUUID()}`;
|
||||
const lease = await acquireComputerExecutionLease(deps.prisma, {
|
||||
computerId: bot.computer.id,
|
||||
runId: manualRunId,
|
||||
botId: bot.id,
|
||||
});
|
||||
try {
|
||||
await provisionComputer(deps, bot.computer.id, {
|
||||
...ctx,
|
||||
screenLeaseId: screenLeaseIdForRun(lease, manualRunId),
|
||||
});
|
||||
} finally {
|
||||
await releaseComputerExecutionLease(deps.prisma, lease);
|
||||
}
|
||||
bot = await deps.prisma.bot.findUniqueOrThrow({
|
||||
where: { id: bot.id },
|
||||
include: { thread: true, computer: true },
|
||||
});
|
||||
}
|
||||
if (!bot.computer?.providerRef || bot.computer.state !== "running") {
|
||||
throw new ORPCError("BAD_REQUEST", { message: "Computer must be running to teach" });
|
||||
}
|
||||
if (bot.computer.kind === "desktop") {
|
||||
throw new ORPCError("BAD_REQUEST", {
|
||||
message: "Teaching needs a graphical sandbox computer, not a desktop host",
|
||||
});
|
||||
}
|
||||
return bot;
|
||||
}
|
||||
|
||||
async function grantTakeover(
|
||||
deps: TaughtSkillsDeps,
|
||||
actor: Actor,
|
||||
bot: Awaited<ReturnType<ReturnType<typeof import("@rakazo/db").createRepos>["getBot"]>>,
|
||||
until: Date,
|
||||
): Promise<{ bot: typeof bot; leaseId: string }> {
|
||||
if (!bot.computer) throw new IsolationError();
|
||||
if (await extendActiveComputerControl(deps.prisma, deps.jobs, bot.computer, bot.id, until)) {
|
||||
const leaseId = bot.computer.controlLeaseId;
|
||||
if (!leaseId) throw new IsolationError();
|
||||
return { bot, leaseId };
|
||||
}
|
||||
const leaseId = randomUUID();
|
||||
const expiresAt = teachingControlLeaseExpiresAt(until);
|
||||
const granted = await deps.prisma.computer.updateMany({
|
||||
where: {
|
||||
id: bot.computer.id,
|
||||
state: "running",
|
||||
OR: [{ controlHolder: { not: "user" } }, { controlBotId: bot.id }],
|
||||
},
|
||||
data: {
|
||||
controlHolder: "user",
|
||||
controlLeaseId: leaseId,
|
||||
controlLeaseExpiresAt: expiresAt,
|
||||
controlBotId: bot.id,
|
||||
state: "running",
|
||||
},
|
||||
});
|
||||
if (granted.count !== 1) {
|
||||
throw new ORPCError("CONFLICT", { message: "Could not take control of the computer" });
|
||||
}
|
||||
await scheduleComputerControlExpiry(deps.jobs, bot.computer.id, leaseId, expiresAt);
|
||||
if (bot.thread) {
|
||||
await deps.events.append({
|
||||
spaceId: actor.spaceId,
|
||||
threadId: bot.thread.id,
|
||||
botId: bot.id,
|
||||
type: "computer.takeover.granted",
|
||||
payload: { holder: "user", reason: "teaching" },
|
||||
});
|
||||
}
|
||||
return { bot, leaseId };
|
||||
}
|
||||
|
||||
async function updateSkillDraftMessage(
|
||||
deps: TaughtSkillsDeps,
|
||||
actor: Actor,
|
||||
skill: TaughtSkillRow,
|
||||
input: {
|
||||
name?: string;
|
||||
playbook?: SkillPlaybook;
|
||||
status?: "draft" | "saved";
|
||||
},
|
||||
): Promise<void> {
|
||||
const bot = await deps.prisma.bot.findUnique({
|
||||
where: { id: skill.botId },
|
||||
include: { thread: true },
|
||||
});
|
||||
if (!bot?.thread) return;
|
||||
|
||||
const messages = await deps.prisma.message.findMany({
|
||||
where: { threadId: bot.thread.id, role: "bot" },
|
||||
orderBy: { seq: "desc" },
|
||||
take: 100,
|
||||
});
|
||||
|
||||
for (const message of messages) {
|
||||
const parsed = message.blocks as MessageBlock[];
|
||||
if (!Array.isArray(parsed)) continue;
|
||||
const index = parsed.findIndex(
|
||||
(block) => block.kind === "skill_draft" && block.skillId === skill.id,
|
||||
);
|
||||
if (index === -1) continue;
|
||||
const existing = parsed[index];
|
||||
if (existing?.kind !== "skill_draft") continue;
|
||||
const playbook = input.playbook ?? parsePlaybook(skill.playbook);
|
||||
const nextBlocks: MessageBlock[] = [...parsed];
|
||||
nextBlocks[index] = {
|
||||
kind: "skill_draft",
|
||||
skillId: skill.id,
|
||||
name: input.name ?? existing.name,
|
||||
goal: skill.goal,
|
||||
playbook,
|
||||
status: input.status ?? existing.status,
|
||||
};
|
||||
await deps.prisma.message.update({
|
||||
where: { id: message.id },
|
||||
data: { blocks: nextBlocks as never },
|
||||
});
|
||||
await deps.events.append({
|
||||
spaceId: actor.spaceId,
|
||||
threadId: bot.thread.id,
|
||||
botId: bot.id,
|
||||
type: "thread.message.updated",
|
||||
payload: { messageId: message.id, role: "bot", blocks: nextBlocks },
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
export async function expireTeachingSessionIfNeeded(deps: TaughtSkillsDeps, skillId: string) {
|
||||
return expireTaughtSkillTeaching(deps, skillId);
|
||||
}
|
||||
|
||||
export async function stopTeachingSession(
|
||||
deps: TaughtSkillsDeps,
|
||||
actor: Actor,
|
||||
skillId: string,
|
||||
): Promise<TaughtSkill> {
|
||||
await getOwnedSkill(deps, actor, skillId);
|
||||
await expireTeachingSessionIfNeeded(deps, skillId);
|
||||
const current = await deps.prisma.taughtSkill.findUniqueOrThrow({ where: { id: skillId } });
|
||||
if (current.status === "draft" || current.status === "saved") {
|
||||
await releaseTeachingComputerControlForBot(
|
||||
deps,
|
||||
actor,
|
||||
current.botId,
|
||||
parseRecording(current.recording).controlLeaseId,
|
||||
);
|
||||
return mapTaughtSkill(current);
|
||||
}
|
||||
if (current.status !== "recording" && current.status !== "drafting") {
|
||||
throw new ORPCError("BAD_REQUEST", { message: "Teaching session is not active" });
|
||||
}
|
||||
const bot = await deps.prisma.bot.findUnique({
|
||||
where: { id: current.botId },
|
||||
include: { thread: true, computer: true },
|
||||
});
|
||||
if (!bot) throw new IsolationError();
|
||||
const stopSnapshot =
|
||||
current.status === "recording" && bot.computer?.providerRef
|
||||
? await observeStopSnapshot(deps, actor, bot)
|
||||
: undefined;
|
||||
const finalized = await completeTeachingSession(deps, actor, skillId, "stopped", stopSnapshot);
|
||||
await deps.jobs.cancel(skillTeachingExpireJobKey(current.id));
|
||||
return mapTaughtSkill(finalized);
|
||||
}
|
||||
|
||||
export function createTaughtSkillsService(deps: TaughtSkillsDeps) {
|
||||
return {
|
||||
async list(actor: Actor, botId: string): Promise<TaughtSkill[]> {
|
||||
const rows = await deps.prisma.taughtSkill.findMany({
|
||||
where: { spaceId: actor.spaceId, botId, userId: actor.userId },
|
||||
orderBy: { updatedAt: "desc" },
|
||||
});
|
||||
return rows.map(mapTaughtSkill);
|
||||
},
|
||||
|
||||
async get(actor: Actor, skillId: string): Promise<TaughtSkill> {
|
||||
const row = await getOwnedSkill(deps, actor, skillId);
|
||||
await expireTeachingSessionIfNeeded(deps, row.id);
|
||||
const current = await deps.prisma.taughtSkill.findUniqueOrThrow({ where: { id: skillId } });
|
||||
return mapTaughtSkill(current);
|
||||
},
|
||||
|
||||
async start(actor: Actor, botId: string, goal: string): Promise<TaughtSkill> {
|
||||
let bot = await deps.prisma.bot.findFirst({
|
||||
where: { id: botId, spaceId: actor.spaceId, userId: actor.userId },
|
||||
include: { thread: true, computer: true },
|
||||
});
|
||||
if (!bot) throw new IsolationError();
|
||||
const alreadyRecording = await deps.prisma.taughtSkill.findFirst({
|
||||
where: { botId, status: "recording" },
|
||||
select: { id: true },
|
||||
});
|
||||
if (alreadyRecording) {
|
||||
throw new ORPCError("CONFLICT", { message: "A teaching session is already active" });
|
||||
}
|
||||
await cancelActiveRuns(deps, actor, botId);
|
||||
bot = await ensureGraphicalComputer(deps, actor, bot);
|
||||
const startedAt = new Date();
|
||||
const expiresAt = new Date(startedAt.getTime() + teachRecordingTtlMs());
|
||||
const { leaseId } = await grantTakeover(deps, actor, bot, expiresAt);
|
||||
let row: TaughtSkillRow;
|
||||
try {
|
||||
row = await deps.prisma.$transaction(async (tx) => {
|
||||
await tx.$executeRaw`SELECT id FROM bots WHERE id = ${botId} FOR UPDATE`;
|
||||
const existing = await tx.taughtSkill.findFirst({
|
||||
where: { botId, status: "recording" },
|
||||
});
|
||||
if (existing) {
|
||||
throw new ORPCError("CONFLICT", { message: "A teaching session is already active" });
|
||||
}
|
||||
return tx.taughtSkill.create({
|
||||
data: {
|
||||
spaceId: actor.spaceId,
|
||||
botId,
|
||||
userId: actor.userId,
|
||||
goal,
|
||||
status: "recording",
|
||||
startedAt,
|
||||
expiresAt,
|
||||
recording: { ...emptyRecording(), controlLeaseId: leaseId } as never,
|
||||
playbook: buildPlaybookFromRecording(goal, []) as never,
|
||||
},
|
||||
});
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof ORPCError) throw error;
|
||||
if (
|
||||
error instanceof Error &&
|
||||
"code" in error &&
|
||||
(error as { code?: string }).code === "P2002"
|
||||
) {
|
||||
throw new ORPCError("CONFLICT", { message: "A teaching session is already active" });
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
await deps.jobs.enqueue(skillTeachingExpireJob(row.id, expiresAt));
|
||||
const withSnapshot = await captureTeachingSnapshot(deps, actor, bot, row);
|
||||
if (bot.thread) {
|
||||
await deps.events.append({
|
||||
spaceId: actor.spaceId,
|
||||
threadId: bot.thread.id,
|
||||
botId: bot.id,
|
||||
type: "skill.teaching.started",
|
||||
payload: { skillId: row.id, goal },
|
||||
});
|
||||
}
|
||||
return mapTaughtSkill(withSnapshot);
|
||||
},
|
||||
|
||||
async appendEvent(
|
||||
actor: Actor,
|
||||
skillId: string,
|
||||
event: TeachRecordingEvent,
|
||||
): Promise<TaughtSkill> {
|
||||
await getOwnedSkill(deps, actor, skillId);
|
||||
await expireTeachingSessionIfNeeded(deps, skillId);
|
||||
const current = await deps.prisma.taughtSkill.findUniqueOrThrow({ where: { id: skillId } });
|
||||
if (current.status !== "recording") {
|
||||
throw new ORPCError("BAD_REQUEST", { message: "Teaching session is not recording" });
|
||||
}
|
||||
const updated = await appendRecordingEvent(deps, skillId, event, { requireRecording: true });
|
||||
if (updated.status !== "recording") {
|
||||
throw new ORPCError("BAD_REQUEST", { message: "Teaching session is not recording" });
|
||||
}
|
||||
return mapTaughtSkill(updated);
|
||||
},
|
||||
|
||||
async snapshot(actor: Actor, skillId: string): Promise<TaughtSkill> {
|
||||
await getOwnedSkill(deps, actor, skillId);
|
||||
await expireTeachingSessionIfNeeded(deps, skillId);
|
||||
const current = await deps.prisma.taughtSkill.findUniqueOrThrow({ where: { id: skillId } });
|
||||
if (current.status !== "recording") {
|
||||
throw new ORPCError("BAD_REQUEST", { message: "Teaching session is not recording" });
|
||||
}
|
||||
const bot = await deps.prisma.bot.findUnique({
|
||||
where: { id: current.botId },
|
||||
include: { computer: true },
|
||||
});
|
||||
if (!bot) throw new IsolationError();
|
||||
const updated = await captureTeachingSnapshot(deps, actor, bot, current);
|
||||
return mapTaughtSkill(updated);
|
||||
},
|
||||
|
||||
stop: (actor: Actor, skillId: string) => stopTeachingSession(deps, actor, skillId),
|
||||
|
||||
async updateDraft(
|
||||
actor: Actor,
|
||||
skillId: string,
|
||||
input: { name?: string; playbook: SkillPlaybook },
|
||||
): Promise<TaughtSkill> {
|
||||
const skill = await getOwnedSkill(deps, actor, skillId);
|
||||
if (skill.status !== "draft" && skill.status !== "saved") {
|
||||
throw new ORPCError("BAD_REQUEST", { message: "Skill is not editable yet" });
|
||||
}
|
||||
const row = await deps.prisma.taughtSkill.update({
|
||||
where: { id: skill.id },
|
||||
data: {
|
||||
name: input.name ?? skill.name,
|
||||
playbook: input.playbook as never,
|
||||
status: skill.status === "saved" ? "saved" : "draft",
|
||||
},
|
||||
});
|
||||
await updateSkillDraftMessage(deps, actor, row, {
|
||||
name: row.name,
|
||||
playbook: parsePlaybook(row.playbook),
|
||||
status: row.status === "saved" ? "saved" : "draft",
|
||||
});
|
||||
return mapTaughtSkill(row);
|
||||
},
|
||||
|
||||
async save(actor: Actor, skillId: string, name?: string): Promise<TaughtSkill> {
|
||||
const skill = await getOwnedSkill(deps, actor, skillId);
|
||||
if (skill.status !== "draft" && skill.status !== "saved") {
|
||||
throw new ORPCError("BAD_REQUEST", { message: "Finish recording before saving" });
|
||||
}
|
||||
const row = await deps.prisma.taughtSkill.update({
|
||||
where: { id: skill.id },
|
||||
data: {
|
||||
status: "saved",
|
||||
name: name ?? (skill.name || skill.goal.slice(0, 80)),
|
||||
},
|
||||
});
|
||||
const bot = await deps.prisma.bot.findUnique({
|
||||
where: { id: row.botId },
|
||||
include: { thread: true },
|
||||
});
|
||||
await updateSkillDraftMessage(deps, actor, row, {
|
||||
name: row.name,
|
||||
playbook: parsePlaybook(row.playbook),
|
||||
status: "saved",
|
||||
});
|
||||
if (bot?.thread) {
|
||||
await deps.events.append({
|
||||
spaceId: actor.spaceId,
|
||||
threadId: bot.thread.id,
|
||||
botId: bot.id,
|
||||
type: "skill.saved",
|
||||
payload: { skillId: row.id, name: row.name },
|
||||
});
|
||||
}
|
||||
return mapTaughtSkill(row);
|
||||
},
|
||||
|
||||
async testRun(actor: Actor, skillId: string, prompt?: string): Promise<{ runId: string }> {
|
||||
const skill = await getOwnedSkill(deps, actor, skillId);
|
||||
if (skill.status !== "saved" && skill.status !== "draft") {
|
||||
throw new ORPCError("BAD_REQUEST", { message: "Skill must be saved or drafted first" });
|
||||
}
|
||||
const bot = await deps.prisma.bot.findUnique({
|
||||
where: { id: skill.botId },
|
||||
include: { thread: true },
|
||||
});
|
||||
if (!bot?.thread) throw new IsolationError();
|
||||
const playbook = parsePlaybook(skill.playbook);
|
||||
const taskPrompt =
|
||||
prompt ?? formatSkillRunPrompt(skill.name || skill.goal.slice(0, 80), playbook, true);
|
||||
const task = await deps.prisma.task.create({
|
||||
data: {
|
||||
spaceId: actor.spaceId,
|
||||
botId: bot.id,
|
||||
threadId: bot.thread.id,
|
||||
userId: actor.userId,
|
||||
prompt: taskPrompt,
|
||||
status: "queued",
|
||||
},
|
||||
});
|
||||
const run = await deps.prisma.run.create({
|
||||
data: {
|
||||
spaceId: actor.spaceId,
|
||||
botId: bot.id,
|
||||
threadId: bot.thread.id,
|
||||
taskId: task.id,
|
||||
userId: actor.userId,
|
||||
status: "queued",
|
||||
trigger: "skill",
|
||||
},
|
||||
});
|
||||
await deps.jobs.enqueue(runContinueJob(run.id));
|
||||
return { runId: run.id };
|
||||
},
|
||||
|
||||
async remove(actor: Actor, skillId: string): Promise<{ ok: true }> {
|
||||
const skill = await getOwnedSkill(deps, actor, skillId);
|
||||
if (skill.status === "recording") {
|
||||
await deps.jobs.cancel(skillTeachingExpireJobKey(skill.id));
|
||||
await releaseTeachingComputerControlForBot(
|
||||
deps,
|
||||
actor,
|
||||
skill.botId,
|
||||
parseRecording(skill.recording).controlLeaseId,
|
||||
);
|
||||
}
|
||||
await deps.prisma.taughtSkill.delete({ where: { id: skill.id } });
|
||||
return { ok: true as const };
|
||||
},
|
||||
|
||||
expireTeachingSessionIfNeeded: (skillId: string) =>
|
||||
expireTeachingSessionIfNeeded(deps, skillId),
|
||||
|
||||
async recordInput(
|
||||
actor: Actor,
|
||||
botId: string,
|
||||
mapped: TeachComputerInput,
|
||||
): Promise<"recorded" | "idle" | "stale"> {
|
||||
return recordTeachingInputEvent(deps, actor, botId, mapped);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
@ -0,0 +1,515 @@
|
|||
import type { PrismaClient } from "@rakazo/db";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { isPeerRun, loadAllMessages, loadMessagePage } from "./thread-message-pages.js";
|
||||
|
||||
describe("thread message pages", () => {
|
||||
it("caches peer-run classification for live events", async () => {
|
||||
const findUnique = vi.fn(async () => ({ trigger: "bot_message" }));
|
||||
const prisma = { run: { findUnique } } as unknown as PrismaClient;
|
||||
const cache = new Map<string, Promise<boolean>>();
|
||||
|
||||
await expect(isPeerRun(prisma, "run-peer", cache)).resolves.toBe(true);
|
||||
await expect(isPeerRun(prisma, "run-peer", cache)).resolves.toBe(true);
|
||||
expect(findUnique).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("keeps peer receipt rows when filtering peer-run output from pages", async () => {
|
||||
const findMany = vi.fn(async () => [
|
||||
{
|
||||
id: "message-reply",
|
||||
threadId: "thread-1",
|
||||
seq: 3,
|
||||
role: "bot",
|
||||
blocks: [{ kind: "text", text: "Echoed peer reply" }],
|
||||
botId: "bot-1",
|
||||
replyToMessageId: null,
|
||||
runId: "run-peer",
|
||||
createdAt: new Date("2026-08-16T00:00:03.000Z"),
|
||||
},
|
||||
{
|
||||
id: "message-received",
|
||||
threadId: "thread-1",
|
||||
seq: 2,
|
||||
role: "user",
|
||||
blocks: [
|
||||
{
|
||||
kind: "bot_message_received",
|
||||
fromBotId: "bot-2",
|
||||
fromBotName: "Coder",
|
||||
text: "Done.",
|
||||
},
|
||||
],
|
||||
botId: null,
|
||||
replyToMessageId: null,
|
||||
runId: "run-peer",
|
||||
createdAt: new Date("2026-08-16T00:00:02.000Z"),
|
||||
},
|
||||
{
|
||||
id: "message-user",
|
||||
threadId: "thread-1",
|
||||
seq: 1,
|
||||
role: "bot",
|
||||
blocks: [{ kind: "text", text: "Visible answer" }],
|
||||
botId: "bot-1",
|
||||
replyToMessageId: null,
|
||||
runId: "run-user",
|
||||
createdAt: new Date("2026-08-16T00:00:01.000Z"),
|
||||
},
|
||||
]);
|
||||
const prisma = {
|
||||
message: { findMany },
|
||||
run: { findMany: vi.fn(async () => [{ id: "run-peer" }]) },
|
||||
} as unknown as PrismaClient;
|
||||
|
||||
const page = await loadMessagePage(prisma, "thread-1", undefined, 3);
|
||||
|
||||
expect(page.messages.map((message) => message.id)).toEqual([
|
||||
"message-user",
|
||||
"message-received",
|
||||
]);
|
||||
});
|
||||
|
||||
it("filters peer-run output when its receipt is outside the loaded page", async () => {
|
||||
const findMany = vi.fn(async () => [
|
||||
{
|
||||
id: "message-peer",
|
||||
threadId: "thread-1",
|
||||
seq: 2,
|
||||
role: "bot",
|
||||
blocks: [{ kind: "text", text: "Echoed peer reply" }],
|
||||
botId: "bot-1",
|
||||
replyToMessageId: null,
|
||||
runId: "run-peer",
|
||||
createdAt: new Date("2026-08-16T00:00:02.000Z"),
|
||||
},
|
||||
{
|
||||
id: "message-user",
|
||||
threadId: "thread-1",
|
||||
seq: 1,
|
||||
role: "bot",
|
||||
blocks: [{ kind: "text", text: "Visible answer" }],
|
||||
botId: "bot-1",
|
||||
replyToMessageId: null,
|
||||
runId: "run-user",
|
||||
createdAt: new Date("2026-08-16T00:00:01.000Z"),
|
||||
},
|
||||
]);
|
||||
const prisma = {
|
||||
message: { findMany },
|
||||
run: { findMany: vi.fn(async () => [{ id: "run-peer" }]) },
|
||||
} as unknown as PrismaClient;
|
||||
|
||||
const page = await loadMessagePage(prisma, "thread-1", undefined, 2);
|
||||
|
||||
expect(page.messages.map((message) => message.id)).toEqual(["message-user"]);
|
||||
});
|
||||
|
||||
it("omits peer around-page targets from the normal transcript", async () => {
|
||||
const findMany = vi.fn(async () => [
|
||||
{
|
||||
id: "message-user",
|
||||
threadId: "thread-1",
|
||||
seq: 4,
|
||||
role: "bot",
|
||||
blocks: [{ kind: "text", text: "Visible answer" }],
|
||||
botId: "bot-1",
|
||||
replyToMessageId: null,
|
||||
runId: "run-user",
|
||||
createdAt: new Date("2026-08-16T00:00:04.000Z"),
|
||||
},
|
||||
{
|
||||
id: "message-peer-activity",
|
||||
threadId: "thread-1",
|
||||
seq: 5,
|
||||
role: "bot",
|
||||
blocks: [{ kind: "steps", steps: [{ label: "Message bot", count: 1 }] }],
|
||||
botId: "bot-1",
|
||||
replyToMessageId: null,
|
||||
runId: "run-peer",
|
||||
createdAt: new Date("2026-08-16T00:00:05.000Z"),
|
||||
},
|
||||
{
|
||||
id: "message-peer-target",
|
||||
threadId: "thread-1",
|
||||
seq: 6,
|
||||
role: "bot",
|
||||
blocks: [{ kind: "text", text: "Peer reply" }],
|
||||
botId: "bot-1",
|
||||
replyToMessageId: null,
|
||||
runId: "run-peer",
|
||||
createdAt: new Date("2026-08-16T00:00:06.000Z"),
|
||||
},
|
||||
]);
|
||||
const count = vi.fn(async () => 1);
|
||||
const runFindMany = vi.fn(async () => [{ id: "run-peer" }]);
|
||||
const prisma = {
|
||||
message: { findMany, count },
|
||||
run: { findMany: runFindMany },
|
||||
} as unknown as PrismaClient;
|
||||
|
||||
const page = await loadMessagePage(prisma, "thread-1", undefined, 4, {
|
||||
messageId: "message-peer-target",
|
||||
seq: 6,
|
||||
});
|
||||
|
||||
expect(page.messages.map((message) => message.id)).toEqual(["message-user"]);
|
||||
expect(runFindMany).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("keeps peer receipt around-page targets in the normal transcript page", async () => {
|
||||
const findMany = vi.fn(async () => [
|
||||
{
|
||||
id: "message-user",
|
||||
threadId: "thread-1",
|
||||
seq: 4,
|
||||
role: "bot",
|
||||
blocks: [{ kind: "text", text: "Visible answer" }],
|
||||
botId: "bot-1",
|
||||
replyToMessageId: null,
|
||||
runId: "run-user",
|
||||
createdAt: new Date("2026-08-16T00:00:04.000Z"),
|
||||
},
|
||||
{
|
||||
id: "message-peer-receipt",
|
||||
threadId: "thread-1",
|
||||
seq: 5,
|
||||
role: "user",
|
||||
blocks: [
|
||||
{
|
||||
kind: "bot_message_received",
|
||||
fromBotId: "bot-2",
|
||||
fromBotName: "Coder",
|
||||
text: "Done.",
|
||||
},
|
||||
],
|
||||
botId: null,
|
||||
replyToMessageId: null,
|
||||
runId: "run-peer",
|
||||
createdAt: new Date("2026-08-16T00:00:05.000Z"),
|
||||
},
|
||||
{
|
||||
id: "message-peer-text",
|
||||
threadId: "thread-1",
|
||||
seq: 6,
|
||||
role: "bot",
|
||||
blocks: [{ kind: "text", text: "Peer reply" }],
|
||||
botId: "bot-1",
|
||||
replyToMessageId: null,
|
||||
runId: "run-peer",
|
||||
createdAt: new Date("2026-08-16T00:00:06.000Z"),
|
||||
},
|
||||
]);
|
||||
const count = vi.fn(async () => 0);
|
||||
const prisma = {
|
||||
message: { findMany, count },
|
||||
run: { findMany: vi.fn(async () => [{ id: "run-peer" }]) },
|
||||
} as unknown as PrismaClient;
|
||||
|
||||
const page = await loadMessagePage(prisma, "thread-1", undefined, 4, {
|
||||
messageId: "message-peer-receipt",
|
||||
seq: 5,
|
||||
});
|
||||
|
||||
expect(page.messages.map((message) => message.id)).toEqual([
|
||||
"message-user",
|
||||
"message-peer-receipt",
|
||||
]);
|
||||
});
|
||||
|
||||
it("returns peer-run output for the dedicated bot messages view", async () => {
|
||||
const findMany = vi.fn(async () => [
|
||||
{
|
||||
id: "message-peer",
|
||||
threadId: "thread-1",
|
||||
seq: 1,
|
||||
role: "bot",
|
||||
blocks: [{ kind: "text", text: "Peer reply" }],
|
||||
botId: "bot-1",
|
||||
replyToMessageId: null,
|
||||
runId: "run-peer",
|
||||
createdAt: new Date("2026-08-16T00:00:01.000Z"),
|
||||
},
|
||||
]);
|
||||
const prisma = {
|
||||
message: { findMany, count: vi.fn(async () => 0) },
|
||||
} as unknown as PrismaClient;
|
||||
|
||||
const page = await loadMessagePage(
|
||||
prisma,
|
||||
"thread-1",
|
||||
undefined,
|
||||
2,
|
||||
{ messageId: "message-peer", seq: 1 },
|
||||
true,
|
||||
);
|
||||
|
||||
expect(page.messages.map((message) => message.id)).toEqual(["message-peer"]);
|
||||
});
|
||||
|
||||
it("scans past a page containing only peer-run output", async () => {
|
||||
const row = (seq: number, runId: string) => ({
|
||||
id: `message-${seq}`,
|
||||
threadId: "thread-1",
|
||||
seq,
|
||||
role: "bot",
|
||||
blocks: [{ kind: "text", text: String(seq) }],
|
||||
botId: "bot-1",
|
||||
replyToMessageId: null,
|
||||
runId,
|
||||
createdAt: new Date("2026-08-16T00:00:00.000Z"),
|
||||
});
|
||||
const findMany = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce([row(4, "run-peer"), row(3, "run-peer"), row(2, "run-peer")])
|
||||
.mockResolvedValueOnce([row(1, "run-user")]);
|
||||
const prisma = {
|
||||
message: { findMany },
|
||||
run: {
|
||||
findMany: vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce([{ id: "run-peer" }])
|
||||
.mockResolvedValueOnce([]),
|
||||
},
|
||||
} as unknown as PrismaClient;
|
||||
|
||||
const page = await loadMessagePage(prisma, "thread-1", undefined, 2);
|
||||
|
||||
expect(page.messages.map((message) => message.id)).toEqual(["message-1"]);
|
||||
expect(findMany).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("scans past a receipt-only page so web can reach older user-visible rows", async () => {
|
||||
const receiptRows = [
|
||||
{
|
||||
id: "message-receipt-b",
|
||||
threadId: "thread-1",
|
||||
seq: 3,
|
||||
role: "user",
|
||||
blocks: [
|
||||
{
|
||||
kind: "bot_message_received",
|
||||
fromBotId: "bot-2",
|
||||
fromBotName: "Coder",
|
||||
text: "Done.",
|
||||
},
|
||||
],
|
||||
botId: null,
|
||||
replyToMessageId: null,
|
||||
runId: "run-peer",
|
||||
createdAt: new Date("2026-08-16T00:00:03.000Z"),
|
||||
},
|
||||
{
|
||||
id: "message-receipt-a",
|
||||
threadId: "thread-1",
|
||||
seq: 2,
|
||||
role: "user",
|
||||
blocks: [
|
||||
{
|
||||
kind: "bot_message_sent",
|
||||
toBotId: "bot-2",
|
||||
toBotName: "Coder",
|
||||
text: "Check this.",
|
||||
},
|
||||
],
|
||||
botId: null,
|
||||
replyToMessageId: null,
|
||||
runId: "run-user",
|
||||
createdAt: new Date("2026-08-16T00:00:02.000Z"),
|
||||
},
|
||||
{
|
||||
id: "message-lookahead",
|
||||
threadId: "thread-1",
|
||||
seq: 1,
|
||||
role: "bot",
|
||||
blocks: [{ kind: "text", text: "Older visible answer" }],
|
||||
botId: "bot-1",
|
||||
replyToMessageId: null,
|
||||
runId: "run-user",
|
||||
createdAt: new Date("2026-08-16T00:00:01.000Z"),
|
||||
},
|
||||
];
|
||||
const olderRows = [
|
||||
{
|
||||
id: "message-user",
|
||||
threadId: "thread-1",
|
||||
seq: 1,
|
||||
role: "bot",
|
||||
blocks: [{ kind: "text", text: "Older visible answer" }],
|
||||
botId: "bot-1",
|
||||
replyToMessageId: null,
|
||||
runId: "run-user",
|
||||
createdAt: new Date("2026-08-16T00:00:01.000Z"),
|
||||
},
|
||||
];
|
||||
const findMany = vi.fn().mockResolvedValueOnce(receiptRows).mockResolvedValueOnce(olderRows);
|
||||
const prisma = {
|
||||
message: { findMany },
|
||||
run: {
|
||||
findMany: vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce([{ id: "run-peer" }])
|
||||
.mockResolvedValueOnce([]),
|
||||
},
|
||||
} as unknown as PrismaClient;
|
||||
|
||||
const page = await loadMessagePage(prisma, "thread-1", undefined, 2);
|
||||
|
||||
expect(page.messages.map((message) => message.id)).toEqual(["message-user"]);
|
||||
expect(findMany).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("returns a receipt-only page when the client displays peer receipts", async () => {
|
||||
const receipt = (seq: number) => ({
|
||||
id: `message-receipt-${seq}`,
|
||||
threadId: "thread-1",
|
||||
seq,
|
||||
role: "user",
|
||||
blocks: [
|
||||
{
|
||||
kind: "bot_message_received",
|
||||
fromBotId: "bot-2",
|
||||
fromBotName: "Coder",
|
||||
text: "Done.",
|
||||
},
|
||||
],
|
||||
botId: null,
|
||||
replyToMessageId: null,
|
||||
runId: "run-peer",
|
||||
createdAt: new Date(`2026-08-16T00:00:0${seq}.000Z`),
|
||||
});
|
||||
const findMany = vi.fn(async () => [receipt(3), receipt(2), receipt(1)]);
|
||||
const prisma = {
|
||||
message: { findMany },
|
||||
run: { findMany: vi.fn(async () => [{ id: "run-peer" }]) },
|
||||
} as unknown as PrismaClient;
|
||||
|
||||
const page = await loadMessagePage(prisma, "thread-1", undefined, 2, undefined, false, true);
|
||||
|
||||
expect(page.messages.map((message) => message.id)).toEqual([
|
||||
"message-receipt-2",
|
||||
"message-receipt-3",
|
||||
]);
|
||||
expect(page.olderCursor).toBe(2);
|
||||
expect(findMany).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("queries before the cursor and returns an ascending bounded page", async () => {
|
||||
const findMany = vi.fn(async () =>
|
||||
[5, 4, 3].map((seq) => ({
|
||||
id: `message-${seq}`,
|
||||
threadId: "thread-1",
|
||||
seq,
|
||||
role: "bot",
|
||||
blocks: [{ kind: "text", text: String(seq) }],
|
||||
runId: null,
|
||||
thumbsUp: seq === 4,
|
||||
createdAt: new Date(`2026-08-16T00:00:0${seq}.000Z`),
|
||||
})),
|
||||
);
|
||||
const prisma = { message: { findMany } } as unknown as PrismaClient;
|
||||
|
||||
const page = await loadMessagePage(prisma, "thread-1", 6, 2);
|
||||
|
||||
expect(findMany).toHaveBeenCalledWith({
|
||||
where: { threadId: "thread-1", seq: { lt: 6 } },
|
||||
orderBy: { seq: "desc" },
|
||||
take: 3,
|
||||
});
|
||||
expect(page.messages.map((message) => message.seq)).toEqual([4, 5]);
|
||||
expect(page.messages[0]?.thumbsUp).toBe(true);
|
||||
expect(page.olderCursor).toBe(4);
|
||||
});
|
||||
|
||||
it("ends pagination when the database returns no lookahead row", async () => {
|
||||
const findMany = vi.fn(async () => [
|
||||
{
|
||||
id: "message-0",
|
||||
threadId: "thread-1",
|
||||
seq: 0,
|
||||
role: "user",
|
||||
blocks: [],
|
||||
runId: null,
|
||||
createdAt: new Date("2026-08-16T00:00:00.000Z"),
|
||||
},
|
||||
]);
|
||||
const prisma = { message: { findMany } } as unknown as PrismaClient;
|
||||
|
||||
const page = await loadMessagePage(prisma, "thread-1", 1, 2);
|
||||
|
||||
expect(page.messages.map((message) => message.seq)).toEqual([0]);
|
||||
expect(page.olderCursor).toBeNull();
|
||||
});
|
||||
|
||||
it("loads a page around a target sequence", async () => {
|
||||
const findFirst = vi.fn(async () => ({ seq: 5 }));
|
||||
const findMany = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce([
|
||||
{
|
||||
id: "message-3",
|
||||
threadId: "thread-1",
|
||||
seq: 3,
|
||||
role: "bot",
|
||||
blocks: [],
|
||||
runId: null,
|
||||
createdAt: new Date(),
|
||||
},
|
||||
{
|
||||
id: "message-4",
|
||||
threadId: "thread-1",
|
||||
seq: 4,
|
||||
role: "bot",
|
||||
blocks: [],
|
||||
runId: null,
|
||||
createdAt: new Date(),
|
||||
},
|
||||
{
|
||||
id: "message-5",
|
||||
threadId: "thread-1",
|
||||
seq: 5,
|
||||
role: "bot",
|
||||
blocks: [],
|
||||
runId: null,
|
||||
createdAt: new Date(),
|
||||
},
|
||||
])
|
||||
.mockResolvedValueOnce(1);
|
||||
const count = vi.fn(async () => 1);
|
||||
const prisma = {
|
||||
message: { findFirst, findMany, count },
|
||||
} as unknown as PrismaClient;
|
||||
|
||||
const page = await loadMessagePage(prisma, "thread-1", undefined, 4, { seq: 5 });
|
||||
|
||||
expect(page.messages.map((message) => message.seq)).toEqual([3, 4, 5]);
|
||||
expect(page.olderCursor).toBe(3);
|
||||
expect(findMany).toHaveBeenCalledWith({
|
||||
where: { threadId: "thread-1", seq: { gte: 3, lte: 7 } },
|
||||
orderBy: { seq: "asc" },
|
||||
take: 4,
|
||||
});
|
||||
});
|
||||
|
||||
it("collects bounded pages into chronological export order", async () => {
|
||||
const row = (seq: number) => ({
|
||||
id: `message-${seq}`,
|
||||
threadId: "thread-1",
|
||||
seq,
|
||||
role: "bot",
|
||||
blocks: [],
|
||||
runId: null,
|
||||
createdAt: new Date("2026-08-16T00:00:00.000Z"),
|
||||
});
|
||||
const findMany = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce([row(4), row(3), row(2)])
|
||||
.mockResolvedValueOnce([row(2), row(1), row(0)])
|
||||
.mockResolvedValueOnce([row(0)]);
|
||||
const prisma = { message: { findMany } } as unknown as PrismaClient;
|
||||
|
||||
const messages = await loadAllMessages(prisma, "thread-1", 2);
|
||||
|
||||
expect(messages.map((message) => message.seq)).toEqual([0, 1, 2, 3, 4]);
|
||||
expect(findMany.mock.calls.map(([query]) => query.where.seq?.lt)).toEqual([undefined, 3, 1]);
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,157 @@
|
|||
import type { MessageBlock, ThreadMessage, ThreadMessagePage } from "@rakazo/contracts";
|
||||
import { isPeerReceiptBlocks } from "@rakazo/core";
|
||||
import type { Prisma, PrismaClient } from "@rakazo/db";
|
||||
|
||||
type MessageDb = PrismaClient | Prisma.TransactionClient;
|
||||
|
||||
export async function loadMessagePage(
|
||||
prisma: MessageDb,
|
||||
threadId: string,
|
||||
before: number | undefined,
|
||||
pageSize: number,
|
||||
around?: { messageId?: string; seq?: number },
|
||||
includePeerRuns = false,
|
||||
includePeerReceipts = false,
|
||||
): Promise<ThreadMessagePage> {
|
||||
if (around) {
|
||||
let targetSeq = around.seq;
|
||||
if (targetSeq === undefined && around.messageId) {
|
||||
const row = await prisma.message.findFirst({
|
||||
where: { id: around.messageId, threadId },
|
||||
select: { seq: true },
|
||||
});
|
||||
targetSeq = row?.seq;
|
||||
}
|
||||
if (targetSeq !== undefined) {
|
||||
const half = Math.floor(pageSize / 2);
|
||||
const minSeq = Math.max(0, targetSeq - half);
|
||||
const maxSeq = targetSeq + half;
|
||||
const rows = await prisma.message.findMany({
|
||||
where: { threadId, seq: { gte: minSeq, lte: maxSeq } },
|
||||
orderBy: { seq: "asc" },
|
||||
take: pageSize,
|
||||
});
|
||||
const first = rows[0];
|
||||
const hasOlder = first
|
||||
? (await prisma.message.count({ where: { threadId, seq: { lt: first.seq } } })) > 0
|
||||
: false;
|
||||
// Peer text/activity stays out of the normal transcript (including the
|
||||
// around target). Receipts remain via withoutPeerRunMessages; full peer
|
||||
// history belongs in the bot-messages overlay (includePeerRuns).
|
||||
const messages = includePeerRuns ? rows : await withoutPeerRunMessages(prisma, rows);
|
||||
return {
|
||||
threadId,
|
||||
messages: messages.map(toThreadMessage),
|
||||
olderCursor: hasOlder ? (first?.seq ?? null) : null,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
let cursor = before;
|
||||
while (true) {
|
||||
const rows = await prisma.message.findMany({
|
||||
where: {
|
||||
threadId,
|
||||
...(cursor === undefined ? {} : { seq: { lt: cursor } }),
|
||||
},
|
||||
orderBy: { seq: "desc" },
|
||||
take: pageSize + 1,
|
||||
});
|
||||
const hasOlder = rows.length > pageSize;
|
||||
const pageRows = rows.slice(0, pageSize).reverse();
|
||||
const visibleRows = includePeerRuns ? pageRows : await withoutPeerRunMessages(prisma, pageRows);
|
||||
// Web hides receipts client-side, so its receipt-only pages keep scanning.
|
||||
// Mobile explicitly retains them and must receive each page for pagination.
|
||||
const hasSubstantive = visibleRows.some(
|
||||
(row) => !isPeerReceiptBlocks(row.blocks as MessageBlock[]),
|
||||
);
|
||||
if (hasSubstantive || includePeerReceipts || !hasOlder || includePeerRuns) {
|
||||
return {
|
||||
threadId,
|
||||
messages: visibleRows.map(toThreadMessage),
|
||||
olderCursor: hasOlder ? (pageRows[0]?.seq ?? null) : null,
|
||||
};
|
||||
}
|
||||
// TODO: only rescan when a raw page is entirely peer output. Consider a run relation if
|
||||
// long peer-only histories make this path hot.
|
||||
cursor = pageRows[0]?.seq;
|
||||
}
|
||||
}
|
||||
|
||||
export async function loadAllMessages(
|
||||
prisma: PrismaClient,
|
||||
threadId: string,
|
||||
pageSize: number,
|
||||
): Promise<ThreadMessage[]> {
|
||||
const pages: ThreadMessage[][] = [];
|
||||
let before: number | undefined;
|
||||
do {
|
||||
const page = await loadMessagePage(prisma, threadId, before, pageSize, undefined, true);
|
||||
pages.push(page.messages);
|
||||
before = page.olderCursor ?? undefined;
|
||||
} while (before !== undefined);
|
||||
return pages.reverse().flat();
|
||||
}
|
||||
|
||||
async function withoutPeerRunMessages<T extends { runId: string | null; blocks: Prisma.JsonValue }>(
|
||||
prisma: MessageDb,
|
||||
rows: T[],
|
||||
): Promise<T[]> {
|
||||
const runIds = [...new Set(rows.flatMap((row) => (row.runId ? [row.runId] : [])))];
|
||||
if (runIds.length === 0) return rows;
|
||||
const peerRuns = await prisma.run.findMany({
|
||||
where: { id: { in: runIds }, trigger: "bot_message" },
|
||||
select: { id: true },
|
||||
});
|
||||
const peerRunIds = new Set(peerRuns.map((run) => run.id));
|
||||
return rows.filter((row) => {
|
||||
if (!row.runId || !peerRunIds.has(row.runId)) return true;
|
||||
// Keep compact sent/received receipts; clients render them as chips.
|
||||
const blocks = row.blocks as MessageBlock[];
|
||||
return blocks.some(
|
||||
(block) => block.kind === "bot_message_sent" || block.kind === "bot_message_received",
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
export async function isPeerRun(
|
||||
prisma: MessageDb,
|
||||
runId: string | undefined,
|
||||
cache: Map<string, Promise<boolean>>,
|
||||
): Promise<boolean> {
|
||||
if (!runId) return false;
|
||||
let peerRun = cache.get(runId);
|
||||
if (!peerRun) {
|
||||
peerRun = prisma.run
|
||||
.findUnique({ where: { id: runId }, select: { trigger: true } })
|
||||
.then((run) => run?.trigger === "bot_message");
|
||||
cache.set(runId, peerRun);
|
||||
}
|
||||
return peerRun;
|
||||
}
|
||||
|
||||
function toThreadMessage(row: {
|
||||
id: string;
|
||||
threadId: string;
|
||||
seq: number;
|
||||
role: string;
|
||||
blocks: Prisma.JsonValue;
|
||||
botId: string | null;
|
||||
replyToMessageId: string | null;
|
||||
runId: string | null;
|
||||
thumbsUp: boolean;
|
||||
createdAt: Date;
|
||||
}): ThreadMessage {
|
||||
return {
|
||||
id: row.id,
|
||||
threadId: row.threadId,
|
||||
seq: row.seq,
|
||||
role: row.role as ThreadMessage["role"],
|
||||
blocks: row.blocks as ThreadMessage["blocks"],
|
||||
botId: row.botId ?? undefined,
|
||||
replyToMessageId: row.replyToMessageId ?? undefined,
|
||||
runId: row.runId ?? undefined,
|
||||
thumbsUp: row.thumbsUp,
|
||||
createdAt: row.createdAt.toISOString(),
|
||||
};
|
||||
}
|
||||
|
|
@ -0,0 +1,820 @@
|
|||
import type { SandboxProvider } from "@rakazo/adapter-kit";
|
||||
import type { Actor } from "@rakazo/contracts";
|
||||
import type { PrismaClient } from "@rakazo/db";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
cancelSupersededQueuedRuns,
|
||||
reactToThreadMessage,
|
||||
stopThreadRuns,
|
||||
type ThreadTarget,
|
||||
threadHead,
|
||||
threadSnapshot,
|
||||
} from "./thread-target.js";
|
||||
|
||||
describe("threadHead", () => {
|
||||
it("returns the durable cursor without loading a snapshot", async () => {
|
||||
const findFirst = vi.fn().mockResolvedValue({ seq: 12 });
|
||||
const prisma = { event: { findFirst } } as unknown as PrismaClient;
|
||||
const target = { threadId: "thread-1" } as ThreadTarget;
|
||||
|
||||
await expect(threadHead(prisma, target)).resolves.toEqual({
|
||||
threadId: "thread-1",
|
||||
cursor: 12,
|
||||
});
|
||||
expect(findFirst).toHaveBeenCalledWith({
|
||||
where: { threadId: "thread-1" },
|
||||
orderBy: { seq: "desc" },
|
||||
select: { seq: true },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("queued run supersession", () => {
|
||||
it("only cancels queued runs started by user messages or reactions", async () => {
|
||||
const tx = {
|
||||
run: {
|
||||
findMany: vi.fn().mockResolvedValue([{ id: "run-old", taskId: "task-old" }]),
|
||||
updateMany: vi.fn(),
|
||||
},
|
||||
task: { updateMany: vi.fn() },
|
||||
};
|
||||
await cancelSupersededQueuedRuns(tx as never, {
|
||||
threadId: "thread-1",
|
||||
botIds: ["bot-1"],
|
||||
keepRunIds: ["run-new"],
|
||||
});
|
||||
expect(tx.run.findMany).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
where: expect.objectContaining({
|
||||
OR: [{ trigger: "user", sourceMessage: { role: "user" } }, { trigger: "reaction" }],
|
||||
}),
|
||||
}),
|
||||
);
|
||||
expect(tx.run.updateMany).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ where: { id: { in: ["run-old"] } } }),
|
||||
);
|
||||
expect(tx.task.updateMany).toHaveBeenCalledWith({
|
||||
where: { id: { in: ["task-old"] } },
|
||||
data: { status: "cancelled" },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("message thumbs-up", () => {
|
||||
it("wakes once on add and not on replay or removal", async () => {
|
||||
let thumbsUp = false;
|
||||
let busy = false;
|
||||
let eventSeq = 0;
|
||||
const tx = {
|
||||
$queryRaw: vi.fn(async () => [
|
||||
{ id: "message-1", role: "bot", blocks: [{ kind: "text", text: "Done" }], thumbsUp },
|
||||
]),
|
||||
message: {
|
||||
update: vi.fn(async ({ data }: { data: { thumbsUp: boolean } }) => {
|
||||
thumbsUp = data.thumbsUp;
|
||||
return { id: "message-1" };
|
||||
}),
|
||||
},
|
||||
run: {
|
||||
findFirst: vi.fn(async () => (busy ? { id: "run-active" } : null)),
|
||||
create: vi.fn().mockResolvedValue({ id: "run-1", status: "queued" }),
|
||||
findUnique: vi.fn().mockResolvedValue({ status: "queued" }),
|
||||
},
|
||||
task: { create: vi.fn().mockResolvedValue({ id: "task-1" }) },
|
||||
thread: {
|
||||
update: vi.fn(async () => ({ nextEventSeq: ++eventSeq })),
|
||||
},
|
||||
event: {
|
||||
create: vi.fn(async ({ data }: { data: Record<string, unknown> }) => ({
|
||||
id: `event-${eventSeq}`,
|
||||
createdAt: new Date(),
|
||||
...data,
|
||||
})),
|
||||
},
|
||||
};
|
||||
const prisma = {
|
||||
$transaction: vi.fn(async (callback: (client: typeof tx) => unknown) => callback(tx)),
|
||||
} as unknown as PrismaClient;
|
||||
const actor = { spaceId: "workspace-1", userId: "user-1" } as Actor;
|
||||
const target = {
|
||||
kind: "bot",
|
||||
botId: "bot-1",
|
||||
threadId: "thread-1",
|
||||
bot: { computer: null },
|
||||
} as ThreadTarget;
|
||||
|
||||
await expect(
|
||||
reactToThreadMessage({ prisma }, actor, target, "message-1", true),
|
||||
).resolves.toEqual(expect.objectContaining({ changed: true, runId: "run-1" }));
|
||||
await expect(
|
||||
reactToThreadMessage({ prisma }, actor, target, "message-1", true),
|
||||
).resolves.toEqual(expect.objectContaining({ changed: false, runId: null }));
|
||||
await expect(
|
||||
reactToThreadMessage({ prisma }, actor, target, "message-1", false),
|
||||
).resolves.toEqual(expect.objectContaining({ changed: true, runId: null }));
|
||||
busy = true;
|
||||
await expect(
|
||||
reactToThreadMessage({ prisma }, actor, target, "message-1", true),
|
||||
).resolves.toEqual(expect.objectContaining({ changed: true, runId: null }));
|
||||
|
||||
expect(tx.task.create).toHaveBeenCalledOnce();
|
||||
expect(tx.run.create).toHaveBeenCalledOnce();
|
||||
expect(String(tx.$queryRaw.mock.calls[0]?.[0])).toContain("SELECT id FROM threads");
|
||||
expect(String(tx.$queryRaw.mock.calls[0]?.[0])).toContain("FOR UPDATE");
|
||||
expect(String(tx.$queryRaw.mock.calls[1]?.[0])).toContain(
|
||||
'SELECT id, "thumbsUp" FROM messages',
|
||||
);
|
||||
expect(String(tx.$queryRaw.mock.calls[1]?.[0])).toContain("FOR UPDATE");
|
||||
expect(tx.run.create).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
data: expect.objectContaining({ sourceMessageId: "message-1", trigger: "reaction" }),
|
||||
}),
|
||||
);
|
||||
expect(tx.event.create).toHaveBeenCalledTimes(3);
|
||||
expect(tx.event.create).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
data: expect.objectContaining({
|
||||
type: "thread.message.reaction",
|
||||
payload: { messageId: "message-1", thumbsUp: true },
|
||||
}),
|
||||
}),
|
||||
);
|
||||
expect(thumbsUp).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("threadSnapshot", () => {
|
||||
it("reloads tool-only live messages for an active run", async () => {
|
||||
const run = {
|
||||
id: "run-1",
|
||||
botId: "bot-1",
|
||||
threadId: "thread-1",
|
||||
taskId: "task-1",
|
||||
status: "running",
|
||||
trigger: "user",
|
||||
modelProvider: null,
|
||||
modelId: null,
|
||||
error: null,
|
||||
startedAt: null,
|
||||
completedAt: null,
|
||||
createdAt: new Date("2026-08-23T00:00:00.000Z"),
|
||||
};
|
||||
const findManyEvents = vi.fn().mockResolvedValue([
|
||||
{
|
||||
id: "event-1",
|
||||
threadId: "thread-1",
|
||||
botId: "bot-1",
|
||||
seq: 4,
|
||||
type: "agent.tool.called",
|
||||
runId: "run-1",
|
||||
payload: { name: "SLACK_FIND_CHANNELS" },
|
||||
createdAt: new Date("2026-08-23T00:00:00.000Z"),
|
||||
},
|
||||
]);
|
||||
const tx = {
|
||||
$queryRaw: vi.fn().mockResolvedValue([{ id: "thread-1" }]),
|
||||
message: { findMany: vi.fn().mockResolvedValue([]) },
|
||||
event: {
|
||||
findFirst: vi.fn().mockResolvedValue({ seq: 4 }),
|
||||
findMany: findManyEvents,
|
||||
},
|
||||
run: { findFirst: vi.fn().mockResolvedValue(run) },
|
||||
};
|
||||
const prisma = {
|
||||
$transaction: vi.fn(async (callback: (client: typeof tx) => unknown) => callback(tx)),
|
||||
} as unknown as PrismaClient;
|
||||
const target = {
|
||||
kind: "bot",
|
||||
botId: "bot-1",
|
||||
threadId: "thread-1",
|
||||
bot: { computer: null },
|
||||
} as ThreadTarget;
|
||||
|
||||
const snapshot = await threadSnapshot({ prisma }, target);
|
||||
|
||||
expect(tx.$queryRaw).toHaveBeenCalledOnce();
|
||||
expect(findManyEvents).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
where: expect.objectContaining({
|
||||
type: { in: ["thread.progress", "thread.subagent", "agent.tool.called"] },
|
||||
}),
|
||||
}),
|
||||
);
|
||||
expect(snapshot.messages).toEqual([
|
||||
expect.objectContaining({
|
||||
id: "progress:run-1",
|
||||
botId: "bot-1",
|
||||
blocks: [
|
||||
{
|
||||
kind: "steps",
|
||||
steps: [{ label: "Slack find channels", count: 1 }],
|
||||
},
|
||||
],
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
it("returns the latest failed run so the client can show its error", async () => {
|
||||
const run = {
|
||||
id: "run-failed",
|
||||
botId: "bot-1",
|
||||
threadId: "thread-1",
|
||||
taskId: "task-1",
|
||||
status: "failed",
|
||||
trigger: "user",
|
||||
modelProvider: "openrouter",
|
||||
modelId: "openrouter/unknown",
|
||||
error: "Provider is not configured: openrouter",
|
||||
startedAt: null,
|
||||
completedAt: new Date("2026-08-23T00:00:01.000Z"),
|
||||
createdAt: new Date("2026-08-23T00:00:00.000Z"),
|
||||
};
|
||||
const findManyEvents = vi.fn();
|
||||
const findFirstRun = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce(run)
|
||||
// The failure is itself the newest terminal run, so it stays visible.
|
||||
.mockResolvedValueOnce({ id: run.id });
|
||||
const tx = {
|
||||
$queryRaw: vi.fn().mockResolvedValue([{ id: "thread-1" }]),
|
||||
message: { findMany: vi.fn().mockResolvedValue([]) },
|
||||
event: {
|
||||
findFirst: vi.fn().mockResolvedValue(null),
|
||||
findMany: findManyEvents,
|
||||
},
|
||||
run: { findFirst: findFirstRun },
|
||||
};
|
||||
const prisma = {
|
||||
$transaction: vi.fn(async (callback: (client: typeof tx) => unknown) => callback(tx)),
|
||||
} as unknown as PrismaClient;
|
||||
const target = {
|
||||
kind: "bot",
|
||||
botId: "bot-1",
|
||||
threadId: "thread-1",
|
||||
bot: { computer: null },
|
||||
} as ThreadTarget;
|
||||
|
||||
const snapshot = await threadSnapshot({ prisma }, target);
|
||||
|
||||
expect(findFirstRun).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
where: expect.objectContaining({
|
||||
botId: "bot-1",
|
||||
threadId: "thread-1",
|
||||
trigger: { not: "bot_message" },
|
||||
status: {
|
||||
in: ["queued", "leased", "running", "waiting_input", "waiting_takeover", "failed"],
|
||||
},
|
||||
}),
|
||||
}),
|
||||
);
|
||||
expect(snapshot.run).toEqual(
|
||||
expect.objectContaining({
|
||||
id: "run-failed",
|
||||
status: "failed",
|
||||
error: "Provider is not configured: openrouter",
|
||||
}),
|
||||
);
|
||||
expect(findManyEvents).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("drops a failed run once a newer run has finished", async () => {
|
||||
const failed = {
|
||||
id: "run-failed",
|
||||
botId: "bot-1",
|
||||
threadId: "thread-1",
|
||||
taskId: "task-1",
|
||||
status: "failed",
|
||||
trigger: "user",
|
||||
modelProvider: "openrouter",
|
||||
modelId: "openrouter/unknown",
|
||||
error: "This operation was aborted",
|
||||
startedAt: null,
|
||||
completedAt: new Date("2026-08-23T00:00:01.000Z"),
|
||||
createdAt: new Date("2026-08-23T00:00:00.000Z"),
|
||||
};
|
||||
const findFirstRun = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce(failed)
|
||||
// The supersession probe finds a newer completed run.
|
||||
.mockResolvedValueOnce({ id: "run-completed" });
|
||||
const tx = {
|
||||
$queryRaw: vi.fn().mockResolvedValue([{ id: "thread-1" }]),
|
||||
message: { findMany: vi.fn().mockResolvedValue([]) },
|
||||
event: {
|
||||
findFirst: vi.fn().mockResolvedValue(null),
|
||||
findMany: vi.fn(),
|
||||
},
|
||||
run: { findFirst: findFirstRun },
|
||||
};
|
||||
const prisma = {
|
||||
$transaction: vi.fn(async (callback: (client: typeof tx) => unknown) => callback(tx)),
|
||||
} as unknown as PrismaClient;
|
||||
const target = {
|
||||
kind: "bot",
|
||||
botId: "bot-1",
|
||||
threadId: "thread-1",
|
||||
bot: { computer: null },
|
||||
} as ThreadTarget;
|
||||
|
||||
const snapshot = await threadSnapshot({ prisma }, target);
|
||||
|
||||
expect(findFirstRun).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
expect.objectContaining({
|
||||
where: expect.objectContaining({
|
||||
trigger: { not: "bot_message" },
|
||||
status: { in: ["failed", "completed", "cancelled"] },
|
||||
}),
|
||||
orderBy: [{ createdAt: "desc" }, { id: "desc" }],
|
||||
}),
|
||||
);
|
||||
expect(snapshot.run).toBeNull();
|
||||
});
|
||||
|
||||
it("does not return a cancelled or completed run", async () => {
|
||||
const findManyEvents = vi.fn();
|
||||
const findFirstRun = vi.fn().mockResolvedValue(null);
|
||||
const tx = {
|
||||
$queryRaw: vi.fn().mockResolvedValue([{ id: "thread-1" }]),
|
||||
message: { findMany: vi.fn().mockResolvedValue([]) },
|
||||
event: {
|
||||
findFirst: vi.fn().mockResolvedValue(null),
|
||||
findMany: findManyEvents,
|
||||
},
|
||||
run: { findFirst: findFirstRun },
|
||||
};
|
||||
const prisma = {
|
||||
$transaction: vi.fn(async (callback: (client: typeof tx) => unknown) => callback(tx)),
|
||||
} as unknown as PrismaClient;
|
||||
const target = {
|
||||
kind: "bot",
|
||||
botId: "bot-1",
|
||||
threadId: "thread-1",
|
||||
bot: { computer: null },
|
||||
} as ThreadTarget;
|
||||
|
||||
const snapshot = await threadSnapshot({ prisma }, target);
|
||||
|
||||
expect(findFirstRun).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
where: expect.objectContaining({
|
||||
status: {
|
||||
in: ["queued", "leased", "running", "waiting_input", "waiting_takeover", "failed"],
|
||||
},
|
||||
}),
|
||||
}),
|
||||
);
|
||||
expect(snapshot.run).toBeNull();
|
||||
expect(findManyEvents).not.toHaveBeenCalled();
|
||||
});
|
||||
it("returns a group's latest failed run so a refresh keeps its error", async () => {
|
||||
const run = {
|
||||
id: "run-failed",
|
||||
botId: "bot-2",
|
||||
threadId: "thread-1",
|
||||
taskId: "task-1",
|
||||
status: "failed",
|
||||
trigger: "user",
|
||||
modelProvider: "openrouter",
|
||||
modelId: "openrouter/unknown",
|
||||
error: "member exploded",
|
||||
startedAt: null,
|
||||
completedAt: new Date("2026-08-23T00:00:01.000Z"),
|
||||
createdAt: new Date("2026-08-23T00:00:00.000Z"),
|
||||
};
|
||||
const findManyRuns = groupRunFindMany({ terminals: [run] });
|
||||
const snapshot = await threadSnapshot({ prisma: groupPrisma(findManyRuns) }, groupTarget());
|
||||
|
||||
expect(findManyRuns).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
where: {
|
||||
threadId: "thread-1",
|
||||
trigger: { not: "bot_message" },
|
||||
status: { in: ["failed", "completed", "cancelled"] },
|
||||
},
|
||||
orderBy: [{ updatedAt: "desc" }, { id: "desc" }],
|
||||
take: 50,
|
||||
}),
|
||||
);
|
||||
expect(findManyRuns).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
where: {
|
||||
threadId: "thread-1",
|
||||
trigger: { not: "bot_message" },
|
||||
status: { in: ["queued", "leased", "running", "waiting_input", "waiting_takeover"] },
|
||||
},
|
||||
}),
|
||||
);
|
||||
expect(snapshot.run).toEqual(
|
||||
expect.objectContaining({ id: "run-failed", status: "failed", error: "member exploded" }),
|
||||
);
|
||||
expect(snapshot.activeRuns).toEqual([]);
|
||||
});
|
||||
|
||||
it("omits peer bot_message runs from group activeRuns and displayed terminal run", async () => {
|
||||
const peerActive = {
|
||||
id: "run-peer-active",
|
||||
botId: "bot-a",
|
||||
threadId: "thread-1",
|
||||
taskId: "task-peer",
|
||||
status: "running",
|
||||
trigger: "bot_message",
|
||||
modelProvider: null,
|
||||
modelId: null,
|
||||
error: null,
|
||||
startedAt: new Date("2026-08-23T00:00:05.000Z"),
|
||||
completedAt: null,
|
||||
createdAt: new Date("2026-08-23T00:00:05.000Z"),
|
||||
};
|
||||
const peerFailed = {
|
||||
id: "run-peer-failed",
|
||||
botId: "bot-b",
|
||||
threadId: "thread-1",
|
||||
taskId: "task-peer-fail",
|
||||
status: "failed",
|
||||
trigger: "bot_message",
|
||||
modelProvider: null,
|
||||
modelId: null,
|
||||
error: "peer exploded",
|
||||
startedAt: new Date("2026-08-23T00:00:01.000Z"),
|
||||
completedAt: new Date("2026-08-23T00:00:02.000Z"),
|
||||
createdAt: new Date("2026-08-23T00:00:01.000Z"),
|
||||
};
|
||||
const findManyRuns = groupRunFindMany({
|
||||
active: [peerActive],
|
||||
terminals: [peerFailed],
|
||||
});
|
||||
const snapshot = await threadSnapshot({ prisma: groupPrisma(findManyRuns) }, groupTarget());
|
||||
|
||||
expect(snapshot.activeRuns).toEqual([]);
|
||||
expect(snapshot.run).toBeNull();
|
||||
expect(findManyRuns).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
where: expect.objectContaining({
|
||||
trigger: { not: "bot_message" },
|
||||
status: { in: ["queued", "leased", "running", "waiting_input", "waiting_takeover"] },
|
||||
}),
|
||||
}),
|
||||
);
|
||||
expect(findManyRuns).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
where: expect.objectContaining({
|
||||
trigger: { not: "bot_message" },
|
||||
status: { in: ["failed", "completed", "cancelled"] },
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("does not revive an older group failure after a newer run completed", async () => {
|
||||
const failed = {
|
||||
id: "run-old-failed",
|
||||
botId: "bot-2",
|
||||
threadId: "thread-1",
|
||||
taskId: "task-1",
|
||||
status: "failed",
|
||||
trigger: "user",
|
||||
modelProvider: null,
|
||||
modelId: null,
|
||||
error: "old failure",
|
||||
startedAt: null,
|
||||
completedAt: new Date("2026-08-23T00:00:01.000Z"),
|
||||
createdAt: new Date("2026-08-23T00:00:00.000Z"),
|
||||
};
|
||||
const completed = {
|
||||
id: "run-newer-completed",
|
||||
botId: "bot-1",
|
||||
threadId: "thread-1",
|
||||
taskId: "task-2",
|
||||
status: "completed",
|
||||
trigger: "user",
|
||||
modelProvider: null,
|
||||
modelId: null,
|
||||
error: null,
|
||||
startedAt: new Date("2026-08-23T00:00:02.000Z"),
|
||||
completedAt: new Date("2026-08-23T00:00:04.000Z"),
|
||||
createdAt: new Date("2026-08-23T00:00:02.000Z"),
|
||||
};
|
||||
const snapshot = await threadSnapshot(
|
||||
{ prisma: groupPrisma(groupRunFindMany({ terminals: [completed, failed] })) },
|
||||
groupTarget(),
|
||||
);
|
||||
|
||||
expect(snapshot.run).toBeNull();
|
||||
expect(snapshot.activeRuns).toEqual([]);
|
||||
});
|
||||
|
||||
it("does not revive a failure when a newer cancelled run has null completedAt", async () => {
|
||||
const failed = {
|
||||
id: "run-old-failed",
|
||||
botId: "bot-2",
|
||||
threadId: "thread-1",
|
||||
taskId: "task-1",
|
||||
status: "failed",
|
||||
trigger: "user",
|
||||
modelProvider: null,
|
||||
modelId: null,
|
||||
error: "old failure",
|
||||
startedAt: null,
|
||||
completedAt: new Date("2026-08-23T00:00:01.000Z"),
|
||||
createdAt: new Date("2026-08-23T00:00:00.000Z"),
|
||||
};
|
||||
const cancelled = {
|
||||
id: "run-newer-cancelled",
|
||||
botId: "bot-1",
|
||||
threadId: "thread-1",
|
||||
taskId: "task-2",
|
||||
status: "cancelled",
|
||||
trigger: "user",
|
||||
modelProvider: null,
|
||||
modelId: null,
|
||||
error: null,
|
||||
startedAt: new Date("2026-08-23T00:00:02.000Z"),
|
||||
completedAt: null,
|
||||
createdAt: new Date("2026-08-23T00:00:03.000Z"),
|
||||
};
|
||||
const snapshot = await threadSnapshot(
|
||||
{ prisma: groupPrisma(groupRunFindMany({ terminals: [cancelled, failed] })) },
|
||||
groupTarget(),
|
||||
);
|
||||
|
||||
expect(snapshot.run).toBeNull();
|
||||
});
|
||||
|
||||
it("prefers a timestamped terminal over an older failure with null completedAt", async () => {
|
||||
const failed = {
|
||||
id: "run-old-failed",
|
||||
botId: "bot-2",
|
||||
threadId: "thread-1",
|
||||
taskId: "task-1",
|
||||
status: "failed",
|
||||
trigger: "user",
|
||||
modelProvider: null,
|
||||
modelId: null,
|
||||
error: "old failure",
|
||||
startedAt: null,
|
||||
completedAt: null,
|
||||
createdAt: new Date("2026-08-23T00:00:00.000Z"),
|
||||
};
|
||||
const completed = {
|
||||
id: "run-completed",
|
||||
botId: "bot-1",
|
||||
threadId: "thread-1",
|
||||
taskId: "task-2",
|
||||
status: "completed",
|
||||
trigger: "user",
|
||||
modelProvider: null,
|
||||
modelId: null,
|
||||
error: null,
|
||||
startedAt: new Date("2026-08-23T00:00:02.000Z"),
|
||||
completedAt: new Date("2026-08-23T00:00:04.000Z"),
|
||||
createdAt: new Date("2026-08-23T00:00:02.000Z"),
|
||||
};
|
||||
const snapshot = await threadSnapshot(
|
||||
{ prisma: groupPrisma(groupRunFindMany({ terminals: [failed, completed] })) },
|
||||
groupTarget(),
|
||||
);
|
||||
|
||||
expect(snapshot.run).toBeNull();
|
||||
});
|
||||
|
||||
it("clamps a long persisted group failure error on refresh", async () => {
|
||||
const longError = "x".repeat(400);
|
||||
const run = {
|
||||
id: "run-failed",
|
||||
botId: "bot-2",
|
||||
threadId: "thread-1",
|
||||
taskId: "task-1",
|
||||
status: "failed",
|
||||
trigger: "user",
|
||||
modelProvider: "openrouter",
|
||||
modelId: "openrouter/unknown",
|
||||
error: longError,
|
||||
startedAt: null,
|
||||
completedAt: new Date("2026-08-23T00:00:01.000Z"),
|
||||
createdAt: new Date("2026-08-23T00:00:00.000Z"),
|
||||
};
|
||||
const snapshot = await threadSnapshot(
|
||||
{ prisma: groupPrisma(groupRunFindMany({ terminals: [run] })) },
|
||||
groupTarget(),
|
||||
);
|
||||
|
||||
expect(snapshot.run).toEqual(
|
||||
expect.objectContaining({
|
||||
id: "run-failed",
|
||||
status: "failed",
|
||||
error: `${"x".repeat(300)}…`,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps a concurrent member failure in run while another member is still active", async () => {
|
||||
const active = {
|
||||
id: "run-active",
|
||||
botId: "bot-a",
|
||||
threadId: "thread-1",
|
||||
taskId: "task-a",
|
||||
status: "running",
|
||||
trigger: "user",
|
||||
modelProvider: null,
|
||||
modelId: null,
|
||||
error: null,
|
||||
startedAt: new Date("2026-08-23T00:00:00.000Z"),
|
||||
completedAt: null,
|
||||
createdAt: new Date("2026-08-23T00:00:00.000Z"),
|
||||
};
|
||||
const failed = {
|
||||
id: "run-failed",
|
||||
botId: "bot-b",
|
||||
threadId: "thread-1",
|
||||
taskId: "task-b",
|
||||
status: "failed",
|
||||
trigger: "user",
|
||||
modelProvider: null,
|
||||
modelId: null,
|
||||
error: "member exploded",
|
||||
startedAt: new Date("2026-08-23T00:00:01.000Z"),
|
||||
completedAt: new Date("2026-08-23T00:00:02.000Z"),
|
||||
createdAt: new Date("2026-08-23T00:00:01.000Z"),
|
||||
};
|
||||
const snapshot = await threadSnapshot(
|
||||
{ prisma: groupPrisma(groupRunFindMany({ active: [active], terminals: [failed] })) },
|
||||
groupTarget(),
|
||||
);
|
||||
|
||||
expect(snapshot.run).toEqual(
|
||||
expect.objectContaining({ id: "run-failed", status: "failed", error: "member exploded" }),
|
||||
);
|
||||
expect(snapshot.activeRuns).toEqual([
|
||||
expect.objectContaining({ id: "run-active", status: "running" }),
|
||||
]);
|
||||
});
|
||||
|
||||
it("keeps a failure on refresh when another member starts after it", async () => {
|
||||
const lateActive = {
|
||||
id: "run-late",
|
||||
botId: "bot-a",
|
||||
threadId: "thread-1",
|
||||
taskId: "task-a",
|
||||
status: "running",
|
||||
trigger: "user",
|
||||
modelProvider: null,
|
||||
modelId: null,
|
||||
error: null,
|
||||
startedAt: new Date("2026-08-23T00:00:03.000Z"),
|
||||
completedAt: null,
|
||||
createdAt: new Date("2026-08-23T00:00:03.000Z"),
|
||||
};
|
||||
const failed = {
|
||||
id: "run-failed",
|
||||
botId: "bot-b",
|
||||
threadId: "thread-1",
|
||||
taskId: "task-b",
|
||||
status: "failed",
|
||||
trigger: "user",
|
||||
modelProvider: null,
|
||||
modelId: null,
|
||||
error: "member exploded",
|
||||
startedAt: new Date("2026-08-23T00:00:01.000Z"),
|
||||
completedAt: new Date("2026-08-23T00:00:02.000Z"),
|
||||
createdAt: new Date("2026-08-23T00:00:01.000Z"),
|
||||
};
|
||||
const snapshot = await threadSnapshot(
|
||||
{
|
||||
prisma: groupPrisma(groupRunFindMany({ active: [lateActive], terminals: [failed] })),
|
||||
},
|
||||
groupTarget(),
|
||||
);
|
||||
|
||||
expect(snapshot.run).toEqual(
|
||||
expect.objectContaining({ id: "run-failed", status: "failed", error: "member exploded" }),
|
||||
);
|
||||
expect(snapshot.activeRuns).toEqual([
|
||||
expect.objectContaining({ id: "run-late", status: "running" }),
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
function isTerminalRunQuery(where: { status?: { in?: string[] } } | undefined) {
|
||||
const statuses = where?.status?.in;
|
||||
return Array.isArray(statuses) && statuses.includes("failed") && statuses.includes("completed");
|
||||
}
|
||||
|
||||
function excludesPeerRuns(where: { trigger?: { not?: string } } | undefined) {
|
||||
return where?.trigger?.not === "bot_message";
|
||||
}
|
||||
|
||||
function groupRunFindMany(input: { active?: unknown[]; terminals?: unknown[] }) {
|
||||
return vi
|
||||
.fn()
|
||||
.mockImplementation(
|
||||
async (args: { where?: { status?: { in?: string[] }; trigger?: { not?: string } } }) => {
|
||||
const rows = isTerminalRunQuery(args.where)
|
||||
? (input.terminals ?? [])
|
||||
: (input.active ?? []);
|
||||
if (!excludesPeerRuns(args.where)) return rows;
|
||||
return rows.filter((row) => (row as { trigger?: string }).trigger !== "bot_message");
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
function groupPrisma(findManyRuns: ReturnType<typeof groupRunFindMany>) {
|
||||
const tx = {
|
||||
$queryRaw: vi.fn().mockResolvedValue([{ id: "thread-1" }]),
|
||||
message: { findMany: vi.fn().mockResolvedValue([]) },
|
||||
event: {
|
||||
findFirst: vi.fn().mockResolvedValue(null),
|
||||
findMany: vi.fn().mockResolvedValue([]),
|
||||
},
|
||||
run: { findMany: findManyRuns },
|
||||
};
|
||||
return {
|
||||
$transaction: vi.fn(async (callback: (client: typeof tx) => unknown) => callback(tx)),
|
||||
} as unknown as PrismaClient;
|
||||
}
|
||||
|
||||
function groupTarget() {
|
||||
return {
|
||||
kind: "group",
|
||||
groupId: "group-1",
|
||||
groupName: "Group",
|
||||
members: [],
|
||||
threadId: "thread-1",
|
||||
} as unknown as ThreadTarget;
|
||||
}
|
||||
|
||||
describe("stopThreadRuns", () => {
|
||||
it("releases every active group member screen immediately", async () => {
|
||||
const releaseScreen = vi.fn().mockResolvedValue(undefined);
|
||||
const transaction = {
|
||||
$queryRaw: vi.fn(),
|
||||
run: {
|
||||
findMany: vi.fn().mockResolvedValue([
|
||||
{ id: "run-a", botId: "bot-a" },
|
||||
{ id: "run-b", botId: "bot-b" },
|
||||
]),
|
||||
updateMany: vi.fn().mockResolvedValue({ count: 2 }),
|
||||
},
|
||||
steeringMessage: { deleteMany: vi.fn().mockResolvedValue({ count: 0 }) },
|
||||
};
|
||||
const prisma = {
|
||||
$transaction: vi.fn(async (callback: (client: typeof transaction) => unknown) =>
|
||||
callback(transaction),
|
||||
),
|
||||
computer: {
|
||||
findMany: vi.fn().mockResolvedValue([
|
||||
{
|
||||
homeKey: "home-a",
|
||||
kind: "fake",
|
||||
providerRef: "computer-a",
|
||||
executionBotId: "bot-a",
|
||||
},
|
||||
{
|
||||
homeKey: "home-b",
|
||||
kind: "fake",
|
||||
providerRef: "computer-b",
|
||||
executionBotId: "bot-b",
|
||||
},
|
||||
]),
|
||||
updateMany: vi.fn().mockResolvedValue({ count: 2 }),
|
||||
},
|
||||
computerExecutionLease: { deleteMany: vi.fn().mockResolvedValue({ count: 2 }) },
|
||||
event: { deleteMany: vi.fn().mockResolvedValue({ count: 0 }) },
|
||||
} as unknown as PrismaClient;
|
||||
const actor = {
|
||||
spaceId: "workspace-1",
|
||||
userId: "user-1",
|
||||
} as Actor;
|
||||
const target = {
|
||||
kind: "group",
|
||||
groupId: "group-1",
|
||||
groupName: "Test group",
|
||||
threadId: "thread-1",
|
||||
members: [],
|
||||
memberBotIds: ["bot-a", "bot-b"],
|
||||
} satisfies ThreadTarget;
|
||||
|
||||
await stopThreadRuns(
|
||||
{ prisma, sandbox: { releaseScreen } as unknown as SandboxProvider },
|
||||
actor,
|
||||
target,
|
||||
);
|
||||
|
||||
expect(releaseScreen).toHaveBeenCalledTimes(2);
|
||||
expect(releaseScreen).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ providerRef: "computer-a" }),
|
||||
expect.objectContaining({ spaceId: "workspace-1", userId: "user-1", botId: "bot-a" }),
|
||||
);
|
||||
expect(releaseScreen).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ providerRef: "computer-b" }),
|
||||
expect.objectContaining({ spaceId: "workspace-1", userId: "user-1", botId: "bot-b" }),
|
||||
);
|
||||
expect(prisma.computerExecutionLease.deleteMany).toHaveBeenCalledWith({
|
||||
where: { runId: { in: ["run-a", "run-b"] } },
|
||||
});
|
||||
expect(prisma.computer.updateMany).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ where: { executionRunId: { in: ["run-a", "run-b"] } } }),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,945 @@
|
|||
import { type JobPublisher, runContinueJob } from "@rakazo/adapter-kit";
|
||||
import { toComputerRef } from "@rakazo/adapters";
|
||||
import {
|
||||
type Actor,
|
||||
GROUP_MEMBER_MIN,
|
||||
type GroupMember,
|
||||
parseAvatarShape,
|
||||
type RunStatus,
|
||||
type ThreadSnapshot,
|
||||
} from "@rakazo/contracts";
|
||||
import {
|
||||
ACTIVE_RUN_STATUSES,
|
||||
isActive,
|
||||
projectMessages,
|
||||
resolveGroupTargetBotIds,
|
||||
runFailureError,
|
||||
} from "@rakazo/core";
|
||||
import {
|
||||
appendEventInTransaction,
|
||||
createGroupRepos,
|
||||
createRepos,
|
||||
createThreadMessageInTransaction,
|
||||
IsolationError,
|
||||
lockOwnedGroup,
|
||||
type Prisma,
|
||||
type PrismaClient,
|
||||
type ThreadEvents,
|
||||
touchGroupUpdatedAt,
|
||||
} from "@rakazo/db";
|
||||
import {
|
||||
buildSendPrompt,
|
||||
buildUserMessageBlocks,
|
||||
resolveGroupSendAttachments,
|
||||
resolveSendAttachments,
|
||||
} from "./artifacts.js";
|
||||
import { resolveBusyBotName, toComputerStatus } from "./computer-status.js";
|
||||
import { withSerializableRetry } from "./serializable-retry.js";
|
||||
import { loadMessagePage } from "./thread-message-pages.js";
|
||||
|
||||
export type ThreadTarget =
|
||||
| {
|
||||
kind: "bot";
|
||||
botId: string;
|
||||
threadId: string;
|
||||
bot: Awaited<ReturnType<ReturnType<typeof createRepos>["getBot"]>>;
|
||||
}
|
||||
| {
|
||||
kind: "group";
|
||||
groupId: string;
|
||||
threadId: string;
|
||||
groupName: string;
|
||||
members: GroupMember[];
|
||||
memberBotIds: string[];
|
||||
};
|
||||
|
||||
const THREAD_MESSAGE_PAGE_SIZE = 100;
|
||||
const RUNS_NEEDING_CONTINUE = new Set(["queued"]);
|
||||
|
||||
type MentionTargetInput = string | { kind: "bot" | "group" | "routine" | "connector"; id: string };
|
||||
|
||||
function splitMentionTargets(mentions: MentionTargetInput[] | undefined) {
|
||||
const botMentionIds = new Set<string>();
|
||||
const groupMentionIds = new Set<string>();
|
||||
const routineMentionIds = new Set<string>();
|
||||
const connectorMentionIds = new Set<string>();
|
||||
for (const mention of mentions ?? []) {
|
||||
if (typeof mention === "string") {
|
||||
botMentionIds.add(mention);
|
||||
continue;
|
||||
}
|
||||
if (mention.kind === "bot") botMentionIds.add(mention.id);
|
||||
if (mention.kind === "group") groupMentionIds.add(mention.id);
|
||||
if (mention.kind === "routine") routineMentionIds.add(mention.id);
|
||||
if (mention.kind === "connector") connectorMentionIds.add(mention.id);
|
||||
}
|
||||
return {
|
||||
botMentionIds: [...botMentionIds],
|
||||
groupMentionIds: [...groupMentionIds],
|
||||
routineMentionIds: [...routineMentionIds],
|
||||
connectorMentionIds: [...connectorMentionIds],
|
||||
};
|
||||
}
|
||||
|
||||
async function resolveOwnedConnectorDisplayNames(
|
||||
tx: Prisma.TransactionClient,
|
||||
actor: Actor,
|
||||
connectionIds: string[],
|
||||
) {
|
||||
if (!connectionIds.length) return [];
|
||||
const rows = await tx.connection.findMany({
|
||||
where: {
|
||||
id: { in: connectionIds },
|
||||
spaceId: actor.spaceId,
|
||||
userId: actor.userId,
|
||||
status: "connected",
|
||||
},
|
||||
select: { id: true, displayName: true },
|
||||
});
|
||||
if (rows.length !== connectionIds.length) throw new IsolationError();
|
||||
const byId = new Map(rows.map((row) => [row.id, row.displayName]));
|
||||
return connectionIds.map((id) => byId.get(id) ?? "connector");
|
||||
}
|
||||
|
||||
function sendRunClientNonce(
|
||||
clientNonce: string | undefined,
|
||||
messageId: string,
|
||||
botId?: string,
|
||||
): string | undefined {
|
||||
if (!clientNonce) return undefined;
|
||||
return botId ? `send:${messageId}:${botId}` : `send:${messageId}`;
|
||||
}
|
||||
|
||||
async function enqueueRunsNeedingContinue(
|
||||
jobs: JobPublisher,
|
||||
runs: Array<{ id: string; status: string }>,
|
||||
) {
|
||||
await Promise.all(
|
||||
runs
|
||||
.filter((run) => RUNS_NEEDING_CONTINUE.has(run.status))
|
||||
.map((run) =>
|
||||
jobs.enqueue(runContinueJob(run.id)).catch((error) => {
|
||||
// The queued run is durable; the reconciler repairs a missed immediate wake.
|
||||
console.error("thread send enqueue", error);
|
||||
}),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
async function findSendReceipt(prisma: PrismaClient, threadId: string, clientNonce: string) {
|
||||
return prisma.message.findUnique({
|
||||
where: { threadId_clientNonce: { threadId, clientNonce } },
|
||||
include: { sourceRuns: { orderBy: [{ createdAt: "asc" }, { id: "asc" }] } },
|
||||
});
|
||||
}
|
||||
|
||||
async function replayExistingSend(
|
||||
deps: { prisma: PrismaClient; events: ThreadEvents; jobs: JobPublisher },
|
||||
threadId: string,
|
||||
clientNonce: string | undefined,
|
||||
) {
|
||||
if (!clientNonce) return null;
|
||||
const message = await findSendReceipt(deps.prisma, threadId, clientNonce);
|
||||
if (!message) return null;
|
||||
const receiptEvent = await deps.prisma.event.findFirst({
|
||||
where: {
|
||||
threadId,
|
||||
type: "thread.message.created",
|
||||
payload: { path: ["messageId"], equals: message.id },
|
||||
},
|
||||
orderBy: { seq: "desc" },
|
||||
select: { payload: true },
|
||||
});
|
||||
const receiptRunIds = sendEventRunIds(receiptEvent?.payload);
|
||||
const receiptRuns = receiptRunIds.length
|
||||
? await deps.prisma.run.findMany({ where: { id: { in: receiptRunIds } } })
|
||||
: [];
|
||||
const receiptRunById = new Map(receiptRuns.map((run) => [run.id, run]));
|
||||
const orderedReceiptRuns = receiptRunIds.flatMap((id) => {
|
||||
const run = receiptRunById.get(id);
|
||||
return run ? [run] : [];
|
||||
});
|
||||
const linkedRun =
|
||||
message.sourceRuns[0] ??
|
||||
(message.runId ? await deps.prisma.run.findUnique({ where: { id: message.runId } }) : null);
|
||||
if (!linkedRun && orderedReceiptRuns.length === 0) return null;
|
||||
const runs = orderedReceiptRuns.length
|
||||
? orderedReceiptRuns
|
||||
: message.sourceRuns.length
|
||||
? message.sourceRuns
|
||||
: [linkedRun!];
|
||||
await enqueueRunsNeedingContinue(deps.jobs, runs);
|
||||
const latestEvent = await deps.prisma.event.findFirst({
|
||||
where: { threadId },
|
||||
orderBy: { seq: "desc" },
|
||||
select: { seq: true },
|
||||
});
|
||||
if (latestEvent) {
|
||||
await deps.events.notify(threadId, latestEvent.seq).catch((error) => {
|
||||
// Subscribers catch up from the durable event cursor after a missed realtime wake.
|
||||
console.error("thread send realtime notification", error);
|
||||
});
|
||||
}
|
||||
return sendResult(message, runs);
|
||||
}
|
||||
|
||||
function sendEventRunIds(payload: Prisma.JsonValue | undefined): string[] {
|
||||
if (!payload || typeof payload !== "object" || Array.isArray(payload)) return [];
|
||||
const runIds = (payload as { runIds?: unknown }).runIds;
|
||||
return Array.isArray(runIds) ? runIds.filter((id): id is string => typeof id === "string") : [];
|
||||
}
|
||||
|
||||
function sendResult(message: { seq: number }, runs: Array<{ id: string; taskId: string }>) {
|
||||
const first = runs[0];
|
||||
if (!first) throw new IsolationError("Send did not create a run");
|
||||
return {
|
||||
taskId: first.taskId,
|
||||
runId: first.id,
|
||||
seq: message.seq,
|
||||
runIds: runs.map((run) => run.id),
|
||||
};
|
||||
}
|
||||
|
||||
export async function cancelSupersededQueuedRuns(
|
||||
tx: Prisma.TransactionClient,
|
||||
input: { threadId: string; botIds: string[]; keepRunIds: string[] },
|
||||
) {
|
||||
const superseded = await tx.run.findMany({
|
||||
where: {
|
||||
threadId: input.threadId,
|
||||
botId: { in: input.botIds },
|
||||
status: "queued",
|
||||
OR: [{ trigger: "user", sourceMessage: { role: "user" } }, { trigger: "reaction" }],
|
||||
id: { notIn: input.keepRunIds },
|
||||
},
|
||||
select: { id: true, taskId: true },
|
||||
});
|
||||
if (superseded.length === 0) return;
|
||||
const now = new Date();
|
||||
await tx.run.updateMany({
|
||||
where: { id: { in: superseded.map((run) => run.id) } },
|
||||
data: { status: "cancelled", completedAt: now },
|
||||
});
|
||||
await tx.task.updateMany({
|
||||
where: { id: { in: superseded.map((run) => run.taskId) } },
|
||||
data: { status: "cancelled" },
|
||||
});
|
||||
}
|
||||
|
||||
async function lockAndLoadGroupMembers(
|
||||
tx: Prisma.TransactionClient,
|
||||
actor: Actor,
|
||||
target: Extract<ThreadTarget, { kind: "group" }>,
|
||||
) {
|
||||
await lockOwnedGroup(tx, actor, target.groupId);
|
||||
const group = await tx.chatGroup.findFirst({
|
||||
where: {
|
||||
id: target.groupId,
|
||||
spaceId: actor.spaceId,
|
||||
userId: actor.userId,
|
||||
archivedAt: null,
|
||||
thread: { id: target.threadId },
|
||||
},
|
||||
include: {
|
||||
members: {
|
||||
where: { bot: { archivedAt: null } },
|
||||
include: {
|
||||
bot: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
color: true,
|
||||
avatarShape: true,
|
||||
avatarImageArtifactId: true,
|
||||
updatedAt: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
orderBy: { createdAt: "asc" },
|
||||
},
|
||||
},
|
||||
});
|
||||
if (!group || group.members.length < GROUP_MEMBER_MIN) throw new IsolationError();
|
||||
return group.members.map((member) => ({
|
||||
botId: member.bot.id,
|
||||
name: member.bot.name,
|
||||
color: member.bot.color,
|
||||
avatarShape: parseAvatarShape(member.bot.avatarShape),
|
||||
hasAvatarImage: Boolean(member.bot.avatarImageArtifactId),
|
||||
updatedAt: member.bot.updatedAt.toISOString(),
|
||||
}));
|
||||
}
|
||||
|
||||
export async function resolveThreadTarget(
|
||||
prisma: PrismaClient,
|
||||
actor: Actor,
|
||||
input: { botId?: string; groupId?: string },
|
||||
): Promise<ThreadTarget> {
|
||||
const repos = createRepos(prisma);
|
||||
const groupRepos = createGroupRepos(prisma);
|
||||
if (input.botId) {
|
||||
const bot = await repos.getBot(actor, input.botId);
|
||||
if (!bot.thread) throw new IsolationError();
|
||||
return {
|
||||
kind: "bot",
|
||||
botId: bot.id,
|
||||
threadId: bot.thread.id,
|
||||
bot,
|
||||
};
|
||||
}
|
||||
if (input.groupId) {
|
||||
const group = await groupRepos.getGroupTarget(actor, input.groupId);
|
||||
if (!group.thread) throw new IsolationError();
|
||||
const members = group.members.map((member) => ({
|
||||
botId: member.bot.id,
|
||||
name: member.bot.name,
|
||||
color: member.bot.color,
|
||||
status: member.bot.runs[0]?.status ?? "idle",
|
||||
avatarShape: parseAvatarShape(member.bot.avatarShape),
|
||||
hasAvatarImage: Boolean(member.bot.avatarImageArtifactId),
|
||||
updatedAt: member.bot.updatedAt.toISOString(),
|
||||
}));
|
||||
return {
|
||||
kind: "group",
|
||||
groupId: group.id,
|
||||
threadId: group.thread.id,
|
||||
groupName: group.name,
|
||||
members,
|
||||
memberBotIds: members.map((member) => member.botId),
|
||||
};
|
||||
}
|
||||
throw new IsolationError();
|
||||
}
|
||||
|
||||
export async function threadHead(prisma: PrismaClient, target: ThreadTarget) {
|
||||
const latest = await prisma.event.findFirst({
|
||||
where: { threadId: target.threadId },
|
||||
orderBy: { seq: "desc" },
|
||||
select: { seq: true },
|
||||
});
|
||||
return { threadId: target.threadId, cursor: latest?.seq ?? -1 };
|
||||
}
|
||||
|
||||
export async function threadSnapshot(
|
||||
deps: { prisma: PrismaClient },
|
||||
target: ThreadTarget,
|
||||
): Promise<ThreadSnapshot> {
|
||||
// Lock the thread row so messages, the event cursor, active runs, and live
|
||||
// progress are read from one consistent commit. A torn Promise.all can
|
||||
// otherwise advance the client cursor past thread.message.created while the
|
||||
// ask message page still omits it — leaving waiting_input with no AskCard.
|
||||
if (target.kind === "bot") {
|
||||
const [busyBotName, core] = await Promise.all([
|
||||
resolveBusyBotName(deps.prisma, {
|
||||
computerId: target.bot.computer?.id,
|
||||
botId: target.botId,
|
||||
botName: target.bot.name,
|
||||
}),
|
||||
deps.prisma.$transaction(async (tx) => {
|
||||
await tx.$queryRaw`SELECT id FROM threads WHERE id = ${target.threadId} FOR SHARE`;
|
||||
const [messagePage, last, run] = await Promise.all([
|
||||
loadMessagePage(tx, target.threadId, undefined, THREAD_MESSAGE_PAGE_SIZE),
|
||||
tx.event.findFirst({
|
||||
where: { threadId: target.threadId },
|
||||
orderBy: { seq: "desc" },
|
||||
select: { seq: true },
|
||||
}),
|
||||
tx.run.findFirst({
|
||||
where: {
|
||||
botId: target.botId,
|
||||
threadId: target.threadId,
|
||||
trigger: { not: "bot_message" },
|
||||
status: { in: [...ACTIVE_RUN_STATUSES, "failed"] },
|
||||
},
|
||||
// The id tiebreak keeps ordering deterministic under equal
|
||||
// timestamps, matching the supersession probe below.
|
||||
orderBy: [{ createdAt: "desc" }, { id: "desc" }],
|
||||
}),
|
||||
]);
|
||||
// A failed run is only the thread's word while it is still the newest
|
||||
// terminal run; otherwise a stale failure would resurface in the
|
||||
// composer error strip on every load, forever. Instead of comparing
|
||||
// timestamps (equal createdAt values reverse under gt/gte), ask for
|
||||
// the newest terminal run under the same deterministic ordering and
|
||||
// check whether it is this failure.
|
||||
const newestTerminal =
|
||||
run?.status === "failed"
|
||||
? await tx.run.findFirst({
|
||||
where: {
|
||||
botId: target.botId,
|
||||
threadId: target.threadId,
|
||||
// Match the selection query — peer bot_message runs must not bury a user-visible failure.
|
||||
trigger: { not: "bot_message" },
|
||||
status: { in: ["failed", "completed", "cancelled"] },
|
||||
},
|
||||
orderBy: [{ createdAt: "desc" }, { id: "desc" }],
|
||||
select: { id: true },
|
||||
})
|
||||
: null;
|
||||
const currentRun = run?.status === "failed" && newestTerminal?.id !== run.id ? null : run;
|
||||
const liveEvents =
|
||||
currentRun && isActive(currentRun.status as RunStatus)
|
||||
? await tx.event.findMany({
|
||||
where: {
|
||||
threadId: target.threadId,
|
||||
runId: currentRun.id,
|
||||
type: { in: ["thread.progress", "thread.subagent", "agent.tool.called"] },
|
||||
},
|
||||
orderBy: { seq: "asc" },
|
||||
})
|
||||
: [];
|
||||
return { messagePage, last, run: currentRun, liveEvents };
|
||||
}),
|
||||
]);
|
||||
return {
|
||||
botId: target.botId,
|
||||
threadId: target.threadId,
|
||||
cursor: core.last?.seq ?? -1,
|
||||
messages: messagesWithLiveEvents(core.messagePage.messages, core.liveEvents),
|
||||
olderCursor: core.messagePage.olderCursor,
|
||||
run: core.run ? mapRun(core.run) : null,
|
||||
computer: toComputerStatus(target.botId, target.bot.computer, busyBotName),
|
||||
};
|
||||
}
|
||||
|
||||
const core = await deps.prisma.$transaction(async (tx) => {
|
||||
await tx.$queryRaw`SELECT id FROM threads WHERE id = ${target.threadId} FOR SHARE`;
|
||||
const [messagePage, last, activeRuns, recentTerminals] = await Promise.all([
|
||||
loadMessagePage(tx, target.threadId, undefined, THREAD_MESSAGE_PAGE_SIZE),
|
||||
tx.event.findFirst({
|
||||
where: { threadId: target.threadId },
|
||||
orderBy: { seq: "desc" },
|
||||
select: { seq: true },
|
||||
}),
|
||||
tx.run.findMany({
|
||||
where: {
|
||||
threadId: target.threadId,
|
||||
trigger: { not: "bot_message" },
|
||||
status: { in: [...ACTIVE_RUN_STATUSES] },
|
||||
},
|
||||
orderBy: { createdAt: "desc" },
|
||||
}),
|
||||
// Recently updated terminals (completion bumps updatedAt). pickLatestTerminalRun then
|
||||
// ranks by completedAt ?? createdAt so null timestamps cannot revive a stale failure.
|
||||
tx.run.findMany({
|
||||
where: {
|
||||
threadId: target.threadId,
|
||||
trigger: { not: "bot_message" },
|
||||
status: { in: ["failed", "completed", "cancelled"] },
|
||||
},
|
||||
orderBy: [{ updatedAt: "desc" }, { id: "desc" }],
|
||||
take: 50,
|
||||
}),
|
||||
]);
|
||||
const liveEvents =
|
||||
activeRuns.length > 0
|
||||
? await tx.event.findMany({
|
||||
where: {
|
||||
threadId: target.threadId,
|
||||
runId: { in: activeRuns.map((run) => run.id) },
|
||||
type: { in: ["thread.progress", "thread.subagent", "agent.tool.called"] },
|
||||
},
|
||||
orderBy: { seq: "asc" },
|
||||
})
|
||||
: [];
|
||||
return {
|
||||
messagePage,
|
||||
last,
|
||||
activeRuns,
|
||||
terminalRun: pickLatestTerminalRun(recentTerminals),
|
||||
liveEvents,
|
||||
};
|
||||
});
|
||||
return {
|
||||
groupId: target.groupId,
|
||||
groupName: target.groupName,
|
||||
members: target.members,
|
||||
threadId: target.threadId,
|
||||
cursor: core.last?.seq ?? -1,
|
||||
messages: messagesWithLiveEvents(core.messagePage.messages, core.liveEvents),
|
||||
olderCursor: core.messagePage.olderCursor,
|
||||
// Match the live reducer: a failed latest terminal stays in run even while siblings are
|
||||
// still active or start late. A newer completed/cancelled terminal clears it.
|
||||
run:
|
||||
core.terminalRun?.status === "failed"
|
||||
? mapRun(core.terminalRun)
|
||||
: core.activeRuns[0]
|
||||
? mapRun(core.activeRuns[0])
|
||||
: null,
|
||||
activeRuns: core.activeRuns.map(mapRun),
|
||||
};
|
||||
}
|
||||
|
||||
/** Latest terminal by end time (completedAt, else createdAt), then createdAt, then id. */
|
||||
function pickLatestTerminalRun<T extends { id: string; createdAt: Date; completedAt: Date | null }>(
|
||||
runs: T[],
|
||||
): T | null {
|
||||
if (runs.length === 0) return null;
|
||||
return runs.reduce((best, run) => {
|
||||
const bestEnd = (best.completedAt ?? best.createdAt).getTime();
|
||||
const runEnd = (run.completedAt ?? run.createdAt).getTime();
|
||||
if (runEnd !== bestEnd) return runEnd > bestEnd ? run : best;
|
||||
if (run.createdAt.getTime() !== best.createdAt.getTime()) {
|
||||
return run.createdAt > best.createdAt ? run : best;
|
||||
}
|
||||
return run.id > best.id ? run : best;
|
||||
});
|
||||
}
|
||||
|
||||
function messagesWithLiveEvents(
|
||||
persisted: ThreadSnapshot["messages"],
|
||||
liveEvents: Parameters<typeof projectMessages>[0],
|
||||
) {
|
||||
const live = projectMessages(liveEvents).filter((message) => {
|
||||
if (message.blocks.some((block) => block.kind === "progress" || block.kind === "steps")) {
|
||||
return true;
|
||||
}
|
||||
if (!message.id.startsWith("subagent:")) return false;
|
||||
return !persisted.some((row) =>
|
||||
row.blocks.some(
|
||||
(block) => block.kind === "subagent" && message.id === `subagent:${block.agentId}`,
|
||||
),
|
||||
);
|
||||
});
|
||||
return [...persisted, ...live];
|
||||
}
|
||||
|
||||
function mapRun(run: {
|
||||
id: string;
|
||||
botId: string;
|
||||
threadId: string;
|
||||
taskId: string;
|
||||
status: string;
|
||||
trigger: string;
|
||||
routineId: string | null;
|
||||
modelProvider: string | null;
|
||||
modelId: string | null;
|
||||
error: string | null;
|
||||
startedAt: Date | null;
|
||||
completedAt: Date | null;
|
||||
createdAt: Date;
|
||||
}) {
|
||||
return {
|
||||
id: run.id,
|
||||
botId: run.botId,
|
||||
threadId: run.threadId,
|
||||
taskId: run.taskId,
|
||||
status: run.status as never,
|
||||
trigger: run.trigger as never,
|
||||
routineId: run.routineId ?? null,
|
||||
modelProvider: run.modelProvider,
|
||||
modelId: run.modelId,
|
||||
// Same display clamp as live run.failed events so a huge stored error cannot bypass it.
|
||||
error:
|
||||
run.status === "failed"
|
||||
? runFailureError({ type: "run.failed", payload: { error: run.error } })
|
||||
: run.error,
|
||||
startedAt: run.startedAt?.toISOString() ?? null,
|
||||
completedAt: run.completedAt?.toISOString() ?? null,
|
||||
createdAt: run.createdAt.toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
export async function sendThreadMessage(
|
||||
deps: {
|
||||
prisma: PrismaClient;
|
||||
events: ThreadEvents;
|
||||
jobs: JobPublisher;
|
||||
},
|
||||
actor: Actor,
|
||||
target: ThreadTarget,
|
||||
input: {
|
||||
text?: string;
|
||||
artifactIds?: string[];
|
||||
mentions?: MentionTargetInput[];
|
||||
replyToMessageId?: string;
|
||||
clientNonce?: string;
|
||||
},
|
||||
) {
|
||||
const existing = await replayExistingSend(deps, target.threadId, input.clientNonce);
|
||||
if (existing) return existing;
|
||||
|
||||
const commit = () =>
|
||||
deps.prisma.$transaction(async (tx) => {
|
||||
if (input.replyToMessageId) {
|
||||
const reply = await tx.message.findFirst({
|
||||
where: { id: input.replyToMessageId, threadId: target.threadId },
|
||||
select: { id: true },
|
||||
});
|
||||
if (!reply) throw new IsolationError();
|
||||
}
|
||||
|
||||
if (target.kind === "bot") {
|
||||
const mentionTargets = splitMentionTargets(input.mentions);
|
||||
const { blocks: attachmentBlocks, artifacts } = await resolveSendAttachments(
|
||||
{ prisma: tx },
|
||||
actor,
|
||||
target.botId,
|
||||
input.artifactIds,
|
||||
);
|
||||
const connectorNames = await resolveOwnedConnectorDisplayNames(
|
||||
tx,
|
||||
actor,
|
||||
mentionTargets.connectorMentionIds,
|
||||
);
|
||||
const blocks = buildUserMessageBlocks(input.text, attachmentBlocks);
|
||||
const message = await createThreadMessageInTransaction(tx, {
|
||||
threadId: target.threadId,
|
||||
role: "user",
|
||||
blocks,
|
||||
replyToMessageId: input.replyToMessageId,
|
||||
clientNonce: input.clientNonce,
|
||||
});
|
||||
const active = await tx.run.findFirst({
|
||||
where: {
|
||||
threadId: target.threadId,
|
||||
botId: target.botId,
|
||||
status: { in: [...ACTIVE_RUN_STATUSES] },
|
||||
},
|
||||
select: { id: true, taskId: true, status: true },
|
||||
});
|
||||
if (active) {
|
||||
await tx.steeringMessage.create({
|
||||
data: {
|
||||
messageId: message.id,
|
||||
botId: target.botId,
|
||||
userId: actor.userId,
|
||||
runId: active.id,
|
||||
},
|
||||
});
|
||||
await tx.message.update({ where: { id: message.id }, data: { runId: active.id } });
|
||||
const event = await appendEventInTransaction(tx, {
|
||||
spaceId: actor.spaceId,
|
||||
threadId: target.threadId,
|
||||
botId: target.botId,
|
||||
type: "thread.message.created",
|
||||
runId: active.id,
|
||||
payload: {
|
||||
messageId: message.id,
|
||||
role: "user",
|
||||
blocks,
|
||||
replyToMessageId: input.replyToMessageId,
|
||||
},
|
||||
});
|
||||
return { message, runs: [active], eventSeq: event.seq };
|
||||
}
|
||||
const task = await tx.task.create({
|
||||
data: {
|
||||
spaceId: actor.spaceId,
|
||||
botId: target.botId,
|
||||
threadId: target.threadId,
|
||||
userId: actor.userId,
|
||||
prompt: buildSendPrompt(input.text, artifacts, connectorNames),
|
||||
status: "queued",
|
||||
},
|
||||
});
|
||||
const run = await tx.run.create({
|
||||
data: {
|
||||
spaceId: actor.spaceId,
|
||||
botId: target.botId,
|
||||
threadId: target.threadId,
|
||||
taskId: task.id,
|
||||
userId: actor.userId,
|
||||
status: "queued",
|
||||
trigger: "user",
|
||||
clientNonce: sendRunClientNonce(input.clientNonce, message.id),
|
||||
sourceMessageId: message.id,
|
||||
},
|
||||
});
|
||||
await tx.message.update({ where: { id: message.id }, data: { runId: run.id } });
|
||||
await cancelSupersededQueuedRuns(tx, {
|
||||
threadId: target.threadId,
|
||||
botIds: [target.botId],
|
||||
keepRunIds: [run.id],
|
||||
});
|
||||
const event = await appendEventInTransaction(tx, {
|
||||
spaceId: actor.spaceId,
|
||||
threadId: target.threadId,
|
||||
botId: target.botId,
|
||||
type: "thread.message.created",
|
||||
runId: run.id,
|
||||
payload: {
|
||||
messageId: message.id,
|
||||
role: "user",
|
||||
blocks,
|
||||
runIds: [run.id],
|
||||
replyToMessageId: input.replyToMessageId,
|
||||
},
|
||||
});
|
||||
return { message, runs: [run], eventSeq: event.seq };
|
||||
}
|
||||
|
||||
const members = await lockAndLoadGroupMembers(tx, actor, target);
|
||||
const memberBotIds = members.map((member) => member.botId);
|
||||
const mentionTargets = splitMentionTargets(input.mentions);
|
||||
const targetBotIds = resolveGroupTargetBotIds({
|
||||
text: input.text ?? "",
|
||||
members: members.map((member) => ({ id: member.botId, name: member.name })),
|
||||
explicitMentions: mentionTargets.botMentionIds,
|
||||
});
|
||||
const { blocks: attachmentBlocks, artifacts } = await resolveGroupSendAttachments(
|
||||
{ prisma: tx },
|
||||
actor,
|
||||
target.groupId,
|
||||
memberBotIds,
|
||||
input.artifactIds,
|
||||
);
|
||||
const connectorNames = await resolveOwnedConnectorDisplayNames(
|
||||
tx,
|
||||
actor,
|
||||
mentionTargets.connectorMentionIds,
|
||||
);
|
||||
const blocks = buildUserMessageBlocks(input.text, attachmentBlocks);
|
||||
const message = await createThreadMessageInTransaction(tx, {
|
||||
threadId: target.threadId,
|
||||
role: "user",
|
||||
blocks,
|
||||
replyToMessageId: input.replyToMessageId,
|
||||
clientNonce: input.clientNonce,
|
||||
});
|
||||
const activeRuns = await tx.run.findMany({
|
||||
where: {
|
||||
threadId: target.threadId,
|
||||
botId: { in: targetBotIds },
|
||||
status: { in: [...ACTIVE_RUN_STATUSES] },
|
||||
},
|
||||
select: { id: true, taskId: true, botId: true, status: true },
|
||||
});
|
||||
const activeByBotId = new Map(activeRuns.map((run) => [run.botId, run]));
|
||||
const runs: Array<{ id: string; taskId: string; botId: string; status: string }> = [];
|
||||
for (const botId of targetBotIds) {
|
||||
const active = activeByBotId.get(botId);
|
||||
if (active) {
|
||||
await tx.steeringMessage.create({
|
||||
data: { messageId: message.id, botId, userId: actor.userId, runId: active.id },
|
||||
});
|
||||
runs.push(active);
|
||||
continue;
|
||||
}
|
||||
const task = await tx.task.create({
|
||||
data: {
|
||||
spaceId: actor.spaceId,
|
||||
botId,
|
||||
threadId: target.threadId,
|
||||
userId: actor.userId,
|
||||
prompt: buildSendPrompt(input.text, artifacts, connectorNames),
|
||||
status: "queued",
|
||||
},
|
||||
});
|
||||
const run = await tx.run.create({
|
||||
data: {
|
||||
spaceId: actor.spaceId,
|
||||
botId,
|
||||
threadId: target.threadId,
|
||||
taskId: task.id,
|
||||
userId: actor.userId,
|
||||
status: "queued",
|
||||
trigger: "user",
|
||||
clientNonce: sendRunClientNonce(input.clientNonce, message.id, botId),
|
||||
sourceMessageId: message.id,
|
||||
},
|
||||
});
|
||||
runs.push(run);
|
||||
}
|
||||
const firstRun = runs[0];
|
||||
const eventBotId = firstRun?.botId ?? targetBotIds[0];
|
||||
if (!eventBotId) throw new IsolationError("Group send did not resolve a target");
|
||||
if (firstRun) {
|
||||
await tx.message.update({ where: { id: message.id }, data: { runId: firstRun.id } });
|
||||
const createdRuns = runs.filter((run) => !activeByBotId.has(run.botId));
|
||||
if (createdRuns.length) {
|
||||
await cancelSupersededQueuedRuns(tx, {
|
||||
threadId: target.threadId,
|
||||
botIds: createdRuns.map((run) => run.botId),
|
||||
keepRunIds: createdRuns.map((run) => run.id),
|
||||
});
|
||||
}
|
||||
}
|
||||
await touchGroupUpdatedAt(tx, target.groupId);
|
||||
const event = await appendEventInTransaction(tx, {
|
||||
spaceId: actor.spaceId,
|
||||
threadId: target.threadId,
|
||||
botId: eventBotId,
|
||||
type: "thread.message.created",
|
||||
runId: firstRun?.id ?? activeRuns[0]?.id,
|
||||
payload: {
|
||||
messageId: message.id,
|
||||
role: "user",
|
||||
blocks,
|
||||
runIds: runs.map((run) => run.id),
|
||||
replyToMessageId: input.replyToMessageId,
|
||||
},
|
||||
});
|
||||
return { message, runs, eventSeq: event.seq };
|
||||
});
|
||||
|
||||
const committed = await withSerializableRetry(commit).catch(async (error) => {
|
||||
const winner = await replayExistingSend(deps, target.threadId, input.clientNonce);
|
||||
if (winner) return { replay: winner } as const;
|
||||
throw error;
|
||||
});
|
||||
if ("replay" in committed) return committed.replay;
|
||||
await deps.events.notify(target.threadId, committed.eventSeq).catch((error) => {
|
||||
// Subscribers catch up from the durable event cursor after a missed realtime wake.
|
||||
console.error("thread send realtime notification", error);
|
||||
});
|
||||
await enqueueRunsNeedingContinue(deps.jobs, committed.runs);
|
||||
return sendResult(committed.message, committed.runs);
|
||||
}
|
||||
|
||||
export async function reactToThreadMessage(
|
||||
deps: { prisma: PrismaClient },
|
||||
actor: Actor,
|
||||
target: ThreadTarget,
|
||||
messageId: string,
|
||||
thumbsUp: boolean,
|
||||
) {
|
||||
return deps.prisma.$transaction(async (tx) => {
|
||||
await tx.$queryRaw`SELECT id FROM threads WHERE id = ${target.threadId} FOR UPDATE`;
|
||||
const [message] = await tx.$queryRaw<
|
||||
Array<{ id: string; thumbsUp: boolean }>
|
||||
>`SELECT id, "thumbsUp" FROM messages WHERE id = ${messageId} AND "threadId" = ${target.threadId} FOR UPDATE`;
|
||||
if (!message) throw new IsolationError();
|
||||
if (message.thumbsUp === thumbsUp) {
|
||||
return { changed: false, eventSeq: null, runId: null };
|
||||
}
|
||||
|
||||
await tx.message.update({ where: { id: message.id }, data: { thumbsUp } });
|
||||
const botId = target.kind === "bot" ? target.botId : target.memberBotIds[0];
|
||||
if (!botId) throw new IsolationError();
|
||||
|
||||
let run: { id: string; status: string } | null = null;
|
||||
if (thumbsUp && target.kind === "bot") {
|
||||
const busy = await tx.run.findFirst({
|
||||
where: { botId, status: { in: ["running", "queued", "leased"] } },
|
||||
select: { id: true },
|
||||
});
|
||||
if (!busy) {
|
||||
const task = await tx.task.create({
|
||||
data: {
|
||||
spaceId: actor.spaceId,
|
||||
botId,
|
||||
threadId: target.threadId,
|
||||
userId: actor.userId,
|
||||
prompt: "The user gave this message a thumbs-up.",
|
||||
status: "queued",
|
||||
},
|
||||
});
|
||||
run = await tx.run.create({
|
||||
data: {
|
||||
spaceId: actor.spaceId,
|
||||
botId,
|
||||
threadId: target.threadId,
|
||||
taskId: task.id,
|
||||
userId: actor.userId,
|
||||
status: "queued",
|
||||
trigger: "reaction",
|
||||
sourceMessageId: message.id,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const event = await appendEventInTransaction(tx, {
|
||||
spaceId: actor.spaceId,
|
||||
threadId: target.threadId,
|
||||
botId,
|
||||
type: "thread.message.reaction",
|
||||
payload: { messageId: message.id, thumbsUp },
|
||||
runId: run?.id,
|
||||
});
|
||||
return { changed: true, eventSeq: event.seq, runId: run?.id ?? null };
|
||||
});
|
||||
}
|
||||
|
||||
export async function stopThreadRuns(
|
||||
deps: {
|
||||
prisma: PrismaClient;
|
||||
sandbox: import("@rakazo/adapter-kit").SandboxProvider;
|
||||
},
|
||||
actor: Actor,
|
||||
target: ThreadTarget,
|
||||
) {
|
||||
const runIds = await deps.prisma.$transaction(async (tx) => {
|
||||
await tx.$queryRaw`SELECT id FROM threads WHERE id = ${target.threadId} FOR UPDATE`;
|
||||
const ids = (
|
||||
await tx.run.findMany({
|
||||
where: {
|
||||
threadId: target.threadId,
|
||||
status: { in: [...ACTIVE_RUN_STATUSES] },
|
||||
},
|
||||
select: { id: true },
|
||||
})
|
||||
).map((run) => run.id);
|
||||
await tx.run.updateMany({
|
||||
where: { id: { in: ids }, status: { in: [...ACTIVE_RUN_STATUSES] } },
|
||||
data: { status: "cancelled", completedAt: new Date() },
|
||||
});
|
||||
await tx.steeringMessage.deleteMany({
|
||||
where: {
|
||||
botId: { in: target.kind === "bot" ? [target.botId] : target.memberBotIds },
|
||||
message: { threadId: target.threadId },
|
||||
},
|
||||
});
|
||||
return ids;
|
||||
});
|
||||
const computers = runIds.length
|
||||
? await deps.prisma.computer.findMany({
|
||||
where: { executionRunId: { in: runIds } },
|
||||
select: {
|
||||
homeKey: true,
|
||||
kind: true,
|
||||
providerRef: true,
|
||||
executionBotId: true,
|
||||
},
|
||||
})
|
||||
: [];
|
||||
await deps.prisma.computerExecutionLease.deleteMany({ where: { runId: { in: runIds } } });
|
||||
await deps.prisma.computer.updateMany({
|
||||
where: { executionRunId: { in: runIds } },
|
||||
data: {
|
||||
executionRunId: null,
|
||||
executionBotId: null,
|
||||
executionLeaseExpiresAt: null,
|
||||
},
|
||||
});
|
||||
await Promise.all(
|
||||
computers.map(async (computer) => {
|
||||
if (!computer.providerRef || !computer.executionBotId) return;
|
||||
await deps.sandbox
|
||||
.releaseScreen?.(toComputerRef(computer), {
|
||||
operationId: "stop",
|
||||
traceId: "stop",
|
||||
spaceId: actor.spaceId,
|
||||
userId: actor.userId,
|
||||
botId: computer.executionBotId,
|
||||
signal: new AbortController().signal,
|
||||
})
|
||||
.catch(() => undefined);
|
||||
}),
|
||||
);
|
||||
await deps.prisma.event.deleteMany({
|
||||
where: {
|
||||
type: "thread.progress",
|
||||
runId: { in: runIds },
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export async function setThreadUnreadState(
|
||||
prisma: PrismaClient,
|
||||
actor: Actor,
|
||||
target: ThreadTarget,
|
||||
unread: boolean,
|
||||
) {
|
||||
const result = await prisma.thread.updateMany({
|
||||
where: {
|
||||
id: target.threadId,
|
||||
spaceId: actor.spaceId,
|
||||
userId: actor.userId,
|
||||
unread: { not: unread },
|
||||
},
|
||||
data: { unread },
|
||||
});
|
||||
if (result.count > 1) throw new IsolationError();
|
||||
}
|
||||
|
|
@ -0,0 +1,49 @@
|
|||
import { Hono } from "hono";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { mountVoiceHttpRoutes, toVoiceStatus, type VoiceDeps } from "./voice.js";
|
||||
|
||||
describe("toVoiceStatus", () => {
|
||||
it("treats a saved key without a voice as configured but not ready", () => {
|
||||
expect(toVoiceStatus({ provider: "elevenlabs", voiceId: "" })).toEqual({
|
||||
configured: true,
|
||||
ready: false,
|
||||
transcribe: true,
|
||||
provider: "elevenlabs",
|
||||
voiceId: "",
|
||||
});
|
||||
});
|
||||
|
||||
it("is ready once a voice is chosen", () => {
|
||||
expect(toVoiceStatus({ provider: "cartesia", voiceId: "katie" }).ready).toBe(true);
|
||||
expect(toVoiceStatus({ provider: "cartesia", voiceId: "katie" }).transcribe).toBe(false);
|
||||
});
|
||||
|
||||
it("is off when nothing is connected", () => {
|
||||
expect(toVoiceStatus(null)).toEqual({
|
||||
configured: false,
|
||||
ready: false,
|
||||
transcribe: false,
|
||||
provider: null,
|
||||
voiceId: "",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("voice HTTP routes", () => {
|
||||
it("rejects unauthenticated speak and transcribe", async () => {
|
||||
const app = new Hono();
|
||||
mountVoiceHttpRoutes(app, {} as VoiceDeps, async () => null);
|
||||
const speak = await app.request("/api/voice/speak", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ text: "hello" }),
|
||||
});
|
||||
const transcribe = await app.request("/api/voice/transcribe", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ audioBase64: "AAAA", mimeType: "audio/webm" }),
|
||||
});
|
||||
expect(speak.status).toBe(401);
|
||||
expect(transcribe.status).toBe(401);
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,341 @@
|
|||
import { ORPCError } from "@orpc/server";
|
||||
import type { AdapterContext } from "@rakazo/adapter-kit";
|
||||
import {
|
||||
createVoiceProvider,
|
||||
type EncryptedSecretStore,
|
||||
isVoiceProviderId,
|
||||
listVoiceCatalog,
|
||||
MAX_SPEAK_CHARS,
|
||||
MAX_TRANSCRIBE_BYTES,
|
||||
NoVoiceConfigured,
|
||||
voiceCatalogEntry,
|
||||
} from "@rakazo/adapters";
|
||||
import type { Actor, VoiceCredential, VoiceStatus } from "@rakazo/contracts";
|
||||
import { toUtterances } from "@rakazo/core";
|
||||
import {
|
||||
deleteUnreferencedCredentialSecret,
|
||||
findDefaultVoiceCredential,
|
||||
findVoiceCredential,
|
||||
IsolationError,
|
||||
newestVoiceCredentialOrder,
|
||||
Prisma,
|
||||
type PrismaClient,
|
||||
selectSpaceVoicePreference,
|
||||
} from "@rakazo/db";
|
||||
import type { Context, Hono } from "hono";
|
||||
import { withSerializableRetry } from "./serializable-retry.js";
|
||||
|
||||
export interface VoiceDeps {
|
||||
prisma: PrismaClient;
|
||||
secrets: EncryptedSecretStore;
|
||||
}
|
||||
|
||||
export { listVoiceCatalog };
|
||||
|
||||
const SPEAK_TIMEOUT_MS = 60_000;
|
||||
|
||||
export function voiceContext(actor: Actor, signal?: AbortSignal): AdapterContext {
|
||||
return {
|
||||
operationId: "voice",
|
||||
traceId: "voice",
|
||||
spaceId: actor.spaceId,
|
||||
userId: actor.userId,
|
||||
signal: signal ?? new AbortController().signal,
|
||||
};
|
||||
}
|
||||
|
||||
export function catalogEntry(provider: string) {
|
||||
return voiceCatalogEntry(provider);
|
||||
}
|
||||
|
||||
export function toVoiceStatus(cred: { provider: string; voiceId: string } | null): VoiceStatus {
|
||||
const entry = cred ? catalogEntry(cred.provider) : undefined;
|
||||
return {
|
||||
configured: Boolean(cred),
|
||||
ready: Boolean(cred?.voiceId),
|
||||
transcribe: Boolean(entry?.transcribe && cred),
|
||||
provider: cred?.provider ?? null,
|
||||
voiceId: cred?.voiceId ?? "",
|
||||
};
|
||||
}
|
||||
|
||||
export function toVoiceCredential(row: {
|
||||
id: string;
|
||||
provider: string;
|
||||
isDefault: boolean;
|
||||
voiceId: string;
|
||||
}): VoiceCredential {
|
||||
return {
|
||||
id: row.id,
|
||||
provider: row.provider,
|
||||
hasKey: true,
|
||||
isDefault: row.isDefault,
|
||||
voiceId: row.voiceId,
|
||||
transcribe: Boolean(catalogEntry(row.provider)?.transcribe),
|
||||
};
|
||||
}
|
||||
|
||||
export async function loadDefaultVoiceCredential(deps: VoiceDeps, actor: Actor) {
|
||||
return loadVoiceCredential(deps, actor);
|
||||
}
|
||||
|
||||
export async function loadVoiceCredential(deps: VoiceDeps, actor: Actor, provider?: string) {
|
||||
const cred = provider
|
||||
? await findVoiceCredential(deps.prisma, actor, provider)
|
||||
: await findDefaultVoiceCredential(deps.prisma, actor);
|
||||
if (!cred) return null;
|
||||
const secret = await deps.prisma.secret.findFirst({
|
||||
where: { id: cred.secretId, userId: actor.userId, spaceId: null },
|
||||
});
|
||||
if (!secret) return null;
|
||||
return { cred, apiKey: deps.secrets.load(secret.ciphertext, secret.id) };
|
||||
}
|
||||
|
||||
export async function resolveVoiceTarget(
|
||||
deps: VoiceDeps,
|
||||
actor: Actor,
|
||||
input: { botId?: string; voiceId?: string },
|
||||
) {
|
||||
let botVoiceId: string | null = null;
|
||||
if (input.botId) {
|
||||
const bot = await deps.prisma.bot.findFirst({
|
||||
where: { id: input.botId, spaceId: actor.spaceId, userId: actor.userId },
|
||||
select: { voiceId: true },
|
||||
});
|
||||
if (!bot) throw new IsolationError();
|
||||
botVoiceId = bot.voiceId;
|
||||
}
|
||||
const loaded = await loadDefaultVoiceCredential(deps, actor);
|
||||
if (!loaded) throw new NoVoiceConfigured("key");
|
||||
const voiceId = input.voiceId || botVoiceId || loaded.cred.voiceId;
|
||||
if (!voiceId) throw new NoVoiceConfigured("voice");
|
||||
return { ...loaded, voiceId };
|
||||
}
|
||||
|
||||
export async function persistVoiceCredential(
|
||||
deps: VoiceDeps,
|
||||
actor: Actor,
|
||||
input: {
|
||||
provider: string;
|
||||
plaintext: string;
|
||||
voiceId?: string;
|
||||
signal?: AbortSignal;
|
||||
},
|
||||
): Promise<VoiceCredential> {
|
||||
if (!isVoiceProviderId(input.provider)) {
|
||||
throw new ORPCError("BAD_REQUEST", { message: "Unknown voice provider." });
|
||||
}
|
||||
const provider = createVoiceProvider(input.provider);
|
||||
const verified = await provider.verify(input.plaintext, voiceContext(actor, input.signal));
|
||||
if (!verified.ok) {
|
||||
throw new ORPCError("BAD_REQUEST", { message: verified.message ?? "That key was rejected." });
|
||||
}
|
||||
let voiceId = input.voiceId?.trim() ?? "";
|
||||
if (!voiceId) {
|
||||
const voices = await provider.listVoices(input.plaintext, voiceContext(actor, input.signal));
|
||||
voiceId = voices[0]?.id ?? "";
|
||||
}
|
||||
const stored = await deps.secrets.put(input.plaintext, voiceContext(actor, input.signal));
|
||||
const cred = await withSerializableRetry(() =>
|
||||
deps.prisma.$transaction(
|
||||
async (tx) => {
|
||||
const existing = await tx.userVoiceCredential.findFirst({
|
||||
where: { userId: actor.userId, provider: input.provider },
|
||||
orderBy: newestVoiceCredentialOrder,
|
||||
});
|
||||
const secret = await tx.secret.create({
|
||||
data: {
|
||||
id: stored.id,
|
||||
userId: actor.userId,
|
||||
spaceId: null,
|
||||
kind: "voice",
|
||||
ciphertext: stored.ciphertext,
|
||||
},
|
||||
});
|
||||
const credential = !existing
|
||||
? await tx.userVoiceCredential.create({
|
||||
data: {
|
||||
userId: actor.userId,
|
||||
provider: input.provider,
|
||||
secretId: secret.id,
|
||||
},
|
||||
})
|
||||
: await tx.userVoiceCredential.update({
|
||||
where: { id: existing.id },
|
||||
data: { secretId: secret.id },
|
||||
});
|
||||
const previousPreference = existing
|
||||
? await tx.spaceVoicePreference.findUnique({
|
||||
where: {
|
||||
spaceId_userId_credentialId: {
|
||||
spaceId: actor.spaceId,
|
||||
userId: actor.userId,
|
||||
credentialId: existing.id,
|
||||
},
|
||||
},
|
||||
})
|
||||
: null;
|
||||
const selectedVoiceId = voiceId || previousPreference?.voiceId || "";
|
||||
await selectSpaceVoicePreference(tx, actor, credential.id, selectedVoiceId);
|
||||
if (existing) {
|
||||
await deleteUnreferencedCredentialSecret(tx, {
|
||||
credentialKind: "voice",
|
||||
credentialId: existing.id,
|
||||
secretId: existing.secretId,
|
||||
});
|
||||
}
|
||||
return { ...credential, isDefault: true, voiceId: selectedVoiceId };
|
||||
},
|
||||
{ isolationLevel: Prisma.TransactionIsolationLevel.Serializable },
|
||||
),
|
||||
);
|
||||
return toVoiceCredential(cred);
|
||||
}
|
||||
|
||||
export async function prepareVoice(
|
||||
deps: VoiceDeps,
|
||||
actor: Actor,
|
||||
input: { text: string; voiceId?: string; botId?: string },
|
||||
) {
|
||||
try {
|
||||
await resolveVoiceTarget(deps, actor, input);
|
||||
} catch (error) {
|
||||
if (error instanceof NoVoiceConfigured) {
|
||||
return { ready: false, utterances: [] as string[] };
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
return { ready: true, utterances: toUtterances(input.text) };
|
||||
}
|
||||
|
||||
export async function synthesizeVoice(
|
||||
deps: VoiceDeps,
|
||||
actor: Actor,
|
||||
input: { text: string; voiceId?: string; botId?: string; signal?: AbortSignal },
|
||||
) {
|
||||
const target = await resolveVoiceTarget(deps, actor, input);
|
||||
const text = input.text.trim();
|
||||
if (!text) throw new ORPCError("BAD_REQUEST", { message: "Nothing to speak." });
|
||||
if (text.length > MAX_SPEAK_CHARS) {
|
||||
throw new ORPCError("BAD_REQUEST", { message: "That utterance is too long to speak." });
|
||||
}
|
||||
const provider = createVoiceProvider(target.cred.provider);
|
||||
return provider.synthesize(
|
||||
{
|
||||
text,
|
||||
voiceId: target.voiceId,
|
||||
apiKey: target.apiKey,
|
||||
signal: input.signal,
|
||||
},
|
||||
voiceContext(actor, input.signal),
|
||||
);
|
||||
}
|
||||
|
||||
export async function transcribeVoice(
|
||||
deps: VoiceDeps,
|
||||
actor: Actor,
|
||||
input: { audio: Uint8Array; mimeType: string; signal?: AbortSignal },
|
||||
) {
|
||||
const loaded = await loadDefaultVoiceCredential(deps, actor);
|
||||
if (!loaded) throw new NoVoiceConfigured("key");
|
||||
const provider = createVoiceProvider(loaded.cred.provider);
|
||||
if (!provider.transcribe) {
|
||||
throw new ORPCError("BAD_REQUEST", {
|
||||
message: "This voice provider does not transcribe audio. Use on-device dictation instead.",
|
||||
});
|
||||
}
|
||||
if (input.audio.byteLength === 0 || input.audio.byteLength > MAX_TRANSCRIBE_BYTES) {
|
||||
throw new ORPCError("BAD_REQUEST", { message: "That recording is empty or too large." });
|
||||
}
|
||||
return provider.transcribe(
|
||||
{
|
||||
audio: input.audio,
|
||||
mimeType: input.mimeType || "audio/webm",
|
||||
apiKey: loaded.apiKey,
|
||||
signal: input.signal,
|
||||
},
|
||||
voiceContext(actor, input.signal),
|
||||
);
|
||||
}
|
||||
|
||||
export function mountVoiceHttpRoutes(
|
||||
app: Hono,
|
||||
deps: VoiceDeps,
|
||||
authenticate: (c: Context) => Promise<Actor | null>,
|
||||
) {
|
||||
app.post("/api/voice/speak", async (c) => {
|
||||
const actor = await authenticate(c);
|
||||
if (!actor) return c.json({ error: "Unauthorized" }, 401);
|
||||
const body = await c.req.json().catch(() => ({}));
|
||||
try {
|
||||
const clip = await synthesizeVoice(deps, actor, {
|
||||
text: String((body as { text?: unknown }).text ?? ""),
|
||||
voiceId: optionalString((body as { voiceId?: unknown }).voiceId),
|
||||
botId: optionalString((body as { botId?: unknown }).botId),
|
||||
signal: AbortSignal.any(
|
||||
[c.req.raw.signal, AbortSignal.timeout(SPEAK_TIMEOUT_MS)].filter(
|
||||
Boolean,
|
||||
) as AbortSignal[],
|
||||
),
|
||||
});
|
||||
// Copy into a fresh ArrayBuffer-backed view: DOM-lib BodyInit rejects
|
||||
// Uint8Array<ArrayBufferLike> since TS 5.7.
|
||||
return new Response(new Uint8Array(clip.bytes), {
|
||||
headers: {
|
||||
"content-type": clip.mimeType,
|
||||
"cache-control": "no-store",
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
return voiceHttpError(c, error);
|
||||
}
|
||||
});
|
||||
|
||||
app.post("/api/voice/transcribe", async (c) => {
|
||||
const actor = await authenticate(c);
|
||||
if (!actor) return c.json({ error: "Unauthorized" }, 401);
|
||||
const body = await c.req.json().catch(() => ({}));
|
||||
const audioBase64 = String((body as { audioBase64?: unknown }).audioBase64 ?? "");
|
||||
try {
|
||||
const audio = decodeAudioBase64(audioBase64);
|
||||
const result = await transcribeVoice(deps, actor, {
|
||||
audio,
|
||||
mimeType: String((body as { mimeType?: unknown }).mimeType ?? "audio/webm"),
|
||||
signal: c.req.raw.signal,
|
||||
});
|
||||
return c.json({ text: result.text });
|
||||
} catch (error) {
|
||||
return voiceHttpError(c, error);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function optionalString(value: unknown): string | undefined {
|
||||
return typeof value === "string" && value.trim() ? value.trim() : undefined;
|
||||
}
|
||||
|
||||
function decodeAudioBase64(value: string): Uint8Array {
|
||||
if (!value.trim()) throw new ORPCError("BAD_REQUEST", { message: "Recording is empty." });
|
||||
try {
|
||||
return new Uint8Array(Buffer.from(value, "base64"));
|
||||
} catch {
|
||||
throw new ORPCError("BAD_REQUEST", { message: "Recording is not valid audio." });
|
||||
}
|
||||
}
|
||||
|
||||
function voiceHttpError(c: Context, error: unknown) {
|
||||
if (error instanceof IsolationError) {
|
||||
return c.json({ error: "Resource not found" }, 404);
|
||||
}
|
||||
if (error instanceof NoVoiceConfigured) {
|
||||
return c.json({ error: error.message }, 409);
|
||||
}
|
||||
if (error instanceof ORPCError) {
|
||||
const code = String(error.code ?? "BAD_REQUEST");
|
||||
const status =
|
||||
code === "UNAUTHORIZED" ? 401 : code === "NOT_FOUND" ? 404 : code === "CONFLICT" ? 409 : 400;
|
||||
return c.json({ error: error.message }, status);
|
||||
}
|
||||
const message = error instanceof Error ? error.message : "Voice request failed.";
|
||||
return c.json({ error: message }, 502);
|
||||
}
|
||||
|
|
@ -0,0 +1,249 @@
|
|||
import { Hono } from "hono";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
formatWebhookPrompt,
|
||||
mountWebhookHttpRoutes,
|
||||
WEBHOOK_MAX_BODY_BYTES,
|
||||
WEBHOOK_SECRET_KIND,
|
||||
type WebhookDeps,
|
||||
} from "./webhook.js";
|
||||
|
||||
const SECRET = "webhook-test-secret-value-32chars!!";
|
||||
|
||||
function createDeps(
|
||||
overrides: {
|
||||
bot?: {
|
||||
id: string;
|
||||
spaceId: string;
|
||||
userId: string;
|
||||
webhookSecretId: string | null;
|
||||
thread: { id: string } | null;
|
||||
} | null;
|
||||
secret?: { ciphertext: string; kind: string; userId: string; spaceId: string } | null;
|
||||
load?: (ciphertext: string) => string;
|
||||
} = {},
|
||||
): WebhookDeps & {
|
||||
sendUserMessage: ReturnType<typeof vi.fn>;
|
||||
enqueue: ReturnType<typeof vi.fn>;
|
||||
} {
|
||||
const bot =
|
||||
overrides.bot === undefined
|
||||
? {
|
||||
id: "bot-1",
|
||||
spaceId: "ws-1",
|
||||
userId: "user-1",
|
||||
webhookSecretId: "secret-1",
|
||||
thread: { id: "thread-1" },
|
||||
}
|
||||
: overrides.bot;
|
||||
const secret =
|
||||
overrides.secret === undefined
|
||||
? {
|
||||
ciphertext: "cipher",
|
||||
kind: WEBHOOK_SECRET_KIND,
|
||||
userId: "user-1",
|
||||
spaceId: "ws-1",
|
||||
}
|
||||
: overrides.secret;
|
||||
|
||||
const sendUserMessage = vi.fn(async () => ({
|
||||
messageId: "msg-1",
|
||||
runId: "run-1",
|
||||
seq: 3,
|
||||
}));
|
||||
const enqueue = vi.fn(async () => undefined);
|
||||
|
||||
return {
|
||||
prisma: {
|
||||
bot: {
|
||||
findUnique: vi.fn(async () => bot),
|
||||
},
|
||||
secret: {
|
||||
findUnique: vi.fn(async () => secret),
|
||||
},
|
||||
routine: {
|
||||
findMany: vi.fn(async () => []),
|
||||
},
|
||||
} as unknown as WebhookDeps["prisma"],
|
||||
secrets: {
|
||||
load: overrides.load ?? (() => SECRET),
|
||||
} as unknown as WebhookDeps["secrets"],
|
||||
events: { sendUserMessage },
|
||||
jobs: { enqueue } as unknown as WebhookDeps["jobs"],
|
||||
sendUserMessage,
|
||||
enqueue,
|
||||
};
|
||||
}
|
||||
|
||||
function mount(deps: WebhookDeps) {
|
||||
const app = new Hono();
|
||||
mountWebhookHttpRoutes(app, deps);
|
||||
return app;
|
||||
}
|
||||
|
||||
describe("formatWebhookPrompt", () => {
|
||||
it("uses payload.text when present", () => {
|
||||
expect(formatWebhookPrompt({ text: " Deployment ok " })).toBe("Deployment ok");
|
||||
});
|
||||
|
||||
it("formats json events with a fence", () => {
|
||||
const prompt = formatWebhookPrompt({ event: "github.push", ref: "main" });
|
||||
expect(prompt).toContain("[Inbound Event: github.push]");
|
||||
expect(prompt).toContain('"ref": "main"');
|
||||
});
|
||||
});
|
||||
|
||||
describe("inbound webhook HTTP route", () => {
|
||||
it("rejects missing authorization", async () => {
|
||||
const deps = createDeps();
|
||||
const app = mount(deps);
|
||||
const res = await app.request("/api/v1/bots/bot-1/webhook", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ text: "hi" }),
|
||||
});
|
||||
expect(res.status).toBe(401);
|
||||
expect(deps.sendUserMessage).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects the wrong bearer secret", async () => {
|
||||
const deps = createDeps();
|
||||
const app = mount(deps);
|
||||
const res = await app.request("/api/v1/bots/bot-1/webhook", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
authorization: "Bearer wrong-secret",
|
||||
"content-type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({ text: "hi" }),
|
||||
});
|
||||
expect(res.status).toBe(401);
|
||||
expect(await res.json()).toEqual({ error: "Unauthorized" });
|
||||
expect(deps.sendUserMessage).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects unknown bots with the same unauthorized response", async () => {
|
||||
const deps = createDeps({ bot: null });
|
||||
const app = mount(deps);
|
||||
const res = await app.request("/api/v1/bots/missing/webhook", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
authorization: `Bearer ${SECRET}`,
|
||||
"content-type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({ text: "hi" }),
|
||||
});
|
||||
expect(res.status).toBe(401);
|
||||
expect(await res.json()).toEqual({ error: "Unauthorized" });
|
||||
});
|
||||
|
||||
it("rejects bots without a configured webhook secret", async () => {
|
||||
const deps = createDeps({
|
||||
bot: {
|
||||
id: "bot-1",
|
||||
spaceId: "ws-1",
|
||||
userId: "user-1",
|
||||
webhookSecretId: null,
|
||||
thread: { id: "thread-1" },
|
||||
},
|
||||
});
|
||||
const app = mount(deps);
|
||||
const res = await app.request("/api/v1/bots/bot-1/webhook", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
authorization: `Bearer ${SECRET}`,
|
||||
"content-type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({ text: "hi" }),
|
||||
});
|
||||
expect(res.status).toBe(401);
|
||||
expect(deps.sendUserMessage).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("accepts a valid secret and JSON payload", async () => {
|
||||
const deps = createDeps();
|
||||
const app = mount(deps);
|
||||
const res = await app.request("/api/v1/bots/bot-1/webhook", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
authorization: `Bearer ${SECRET}`,
|
||||
"content-type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({ event: "ci.failed", repo: "rakazo" }),
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
expect(await res.json()).toEqual({
|
||||
ok: true,
|
||||
messageId: "msg-1",
|
||||
runId: "run-1",
|
||||
seq: 3,
|
||||
});
|
||||
expect(deps.sendUserMessage).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
botId: "bot-1",
|
||||
trigger: "webhook",
|
||||
prompt: expect.stringContaining("[Inbound Event: ci.failed]"),
|
||||
}),
|
||||
);
|
||||
expect(deps.enqueue).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("accepts a plain text payload", async () => {
|
||||
const deps = createDeps();
|
||||
const app = mount(deps);
|
||||
const res = await app.request("/api/v1/bots/bot-1/webhook", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
authorization: `Bearer ${SECRET}`,
|
||||
"content-type": "text/plain",
|
||||
},
|
||||
body: "Staging deploy finished",
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
expect(deps.sendUserMessage).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
trigger: "webhook",
|
||||
prompt: "Staging deploy finished",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("hashes idempotency keys into a fixed-length clientNonce", async () => {
|
||||
const { createHash } = await import("node:crypto");
|
||||
const deps = createDeps();
|
||||
const app = mount(deps);
|
||||
const longKey = `event-${"a".repeat(240)}-unique-suffix`;
|
||||
const res = await app.request("/api/v1/bots/bot-1/webhook", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
authorization: `Bearer ${SECRET}`,
|
||||
"content-type": "application/json",
|
||||
"idempotency-key": longKey,
|
||||
},
|
||||
body: JSON.stringify({ event: "ping" }),
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
const digest = createHash("sha256").update(longKey).digest("base64url");
|
||||
expect(deps.sendUserMessage).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
clientNonce: `webhook:bot-1:${digest}`,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects oversized payloads", async () => {
|
||||
const deps = createDeps();
|
||||
const app = mount(deps);
|
||||
const res = await app.request("/api/v1/bots/bot-1/webhook", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
authorization: `Bearer ${SECRET}`,
|
||||
"content-type": "text/plain",
|
||||
"content-length": String(WEBHOOK_MAX_BODY_BYTES + 1),
|
||||
},
|
||||
body: "x".repeat(WEBHOOK_MAX_BODY_BYTES + 1),
|
||||
});
|
||||
expect(res.status).toBe(413);
|
||||
expect(deps.sendUserMessage).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,202 @@
|
|||
import { createHash } from "node:crypto";
|
||||
import type { JobPublisher } from "@rakazo/adapter-kit";
|
||||
import { runContinueJob } from "@rakazo/adapter-kit";
|
||||
import type { EncryptedSecretStore } from "@rakazo/adapters";
|
||||
import { hasValidBearerToken } from "@rakazo/core";
|
||||
import type { PrismaClient } from "@rakazo/db";
|
||||
import type { Hono } from "hono";
|
||||
|
||||
export const WEBHOOK_MAX_BODY_BYTES = 64 * 1024;
|
||||
export const WEBHOOK_SECRET_KIND = "webhook";
|
||||
|
||||
export type WebhookEvents = {
|
||||
sendUserMessage(input: {
|
||||
spaceId: string;
|
||||
threadId: string;
|
||||
botId: string;
|
||||
userId: string;
|
||||
blocks: Array<{ kind: "text"; text: string }>;
|
||||
prompt: string;
|
||||
trigger: "webhook";
|
||||
clientNonce?: string;
|
||||
}): Promise<{ messageId: string; runId: string | null; seq: number }>;
|
||||
};
|
||||
|
||||
export type WebhookDeps = {
|
||||
prisma: PrismaClient;
|
||||
secrets: EncryptedSecretStore;
|
||||
events: WebhookEvents;
|
||||
jobs: JobPublisher;
|
||||
};
|
||||
|
||||
export function formatWebhookPrompt(payload: Record<string, unknown>): string {
|
||||
if (typeof payload.text === "string" && payload.text.trim()) {
|
||||
return payload.text.trim();
|
||||
}
|
||||
const eventName = typeof payload.event === "string" ? payload.event : "webhook";
|
||||
return `[Inbound Event: ${eventName}]\n\`\`\`json\n${JSON.stringify(payload, null, 2)}\n\`\`\``;
|
||||
}
|
||||
|
||||
export function webhookPath(botId: string): string {
|
||||
return `/api/v1/bots/${botId}/webhook`;
|
||||
}
|
||||
|
||||
export async function readBoundedBody(request: Request, maxBytes: number): Promise<string | null> {
|
||||
const contentLengthHeader = request.headers.get("content-length");
|
||||
if (contentLengthHeader !== null) {
|
||||
const contentLength = Number(contentLengthHeader);
|
||||
if (!Number.isFinite(contentLength) || contentLength < 0 || contentLength > maxBytes) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
if (!request.body) return "";
|
||||
|
||||
const reader = request.body.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let bytes = 0;
|
||||
let body = "";
|
||||
try {
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
bytes += value.byteLength;
|
||||
if (bytes > maxBytes) {
|
||||
await reader.cancel();
|
||||
return null;
|
||||
}
|
||||
body += decoder.decode(value, { stream: true });
|
||||
}
|
||||
return body + decoder.decode();
|
||||
} finally {
|
||||
reader.releaseLock();
|
||||
}
|
||||
}
|
||||
|
||||
function parseWebhookPayload(
|
||||
raw: string,
|
||||
contentType: string | undefined,
|
||||
): Record<string, unknown> {
|
||||
const trimmed = raw.trim();
|
||||
if (!trimmed) return {};
|
||||
const looksJson =
|
||||
contentType?.includes("application/json") || trimmed.startsWith("{") || trimmed.startsWith("[");
|
||||
if (looksJson) {
|
||||
try {
|
||||
const parsed: unknown = JSON.parse(trimmed);
|
||||
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
|
||||
return parsed as Record<string, unknown>;
|
||||
}
|
||||
return { data: parsed };
|
||||
} catch {
|
||||
return { text: trimmed };
|
||||
}
|
||||
}
|
||||
return { text: trimmed };
|
||||
}
|
||||
|
||||
export function mountWebhookHttpRoutes(app: Hono, deps: WebhookDeps) {
|
||||
app.post("/api/v1/bots/:botId/webhook", async (c) => {
|
||||
const unauthorized = () => c.json({ error: "Unauthorized" }, 401);
|
||||
const botId = c.req.param("botId");
|
||||
const authorization = c.req.header("authorization");
|
||||
|
||||
const bot = await deps.prisma.bot.findUnique({
|
||||
where: { id: botId, archivedAt: null },
|
||||
select: {
|
||||
id: true,
|
||||
spaceId: true,
|
||||
userId: true,
|
||||
webhookSecretId: true,
|
||||
thread: { select: { id: true } },
|
||||
},
|
||||
});
|
||||
|
||||
// Same 401 for missing bot, missing secret, and bad bearer so bot ids are not enumerable.
|
||||
if (!bot?.thread || !bot.webhookSecretId) {
|
||||
return unauthorized();
|
||||
}
|
||||
|
||||
const secret = await deps.prisma.secret.findUnique({
|
||||
where: { id: bot.webhookSecretId },
|
||||
select: { id: true, ciphertext: true, kind: true, userId: true, spaceId: true },
|
||||
});
|
||||
if (!secret || secret.kind !== WEBHOOK_SECRET_KIND) {
|
||||
return unauthorized();
|
||||
}
|
||||
if (secret.userId !== bot.userId || secret.spaceId !== bot.spaceId) {
|
||||
return unauthorized();
|
||||
}
|
||||
|
||||
let expected: string;
|
||||
try {
|
||||
expected = deps.secrets.load(secret.ciphertext, secret.id);
|
||||
} catch {
|
||||
return unauthorized();
|
||||
}
|
||||
if (!hasValidBearerToken(authorization, expected)) {
|
||||
return unauthorized();
|
||||
}
|
||||
|
||||
const raw = await readBoundedBody(c.req.raw, WEBHOOK_MAX_BODY_BYTES);
|
||||
if (raw === null) {
|
||||
return c.json({ error: "Payload too large" }, 413);
|
||||
}
|
||||
|
||||
const payload = parseWebhookPayload(raw, c.req.header("content-type"));
|
||||
const eventPrompt = formatWebhookPrompt(payload);
|
||||
|
||||
const webhookRoutines = await deps.prisma.routine.findMany({
|
||||
where: {
|
||||
botId: bot.id,
|
||||
spaceId: bot.spaceId,
|
||||
active: true,
|
||||
webhookEnabled: true,
|
||||
},
|
||||
select: { id: true, name: true, prompt: true },
|
||||
orderBy: { updatedAt: "desc" },
|
||||
take: 5,
|
||||
});
|
||||
|
||||
const promptText =
|
||||
webhookRoutines.length > 0
|
||||
? [
|
||||
...webhookRoutines.map(
|
||||
(routine) => `Run routine "${routine.name}":\n${routine.prompt.trim()}`,
|
||||
),
|
||||
"",
|
||||
"Inbound webhook payload:",
|
||||
eventPrompt,
|
||||
].join("\n")
|
||||
: eventPrompt;
|
||||
|
||||
const idempotencyKey =
|
||||
c.req.header("idempotency-key")?.trim() ||
|
||||
c.req.header("x-idempotency-key")?.trim() ||
|
||||
(typeof payload.id === "string" ? payload.id.trim() : "") ||
|
||||
(typeof payload.event_id === "string" ? payload.event_id.trim() : "") ||
|
||||
undefined;
|
||||
const clientNonce = idempotencyKey
|
||||
? `webhook:${bot.id}:${createHash("sha256").update(idempotencyKey).digest("base64url")}`
|
||||
: undefined;
|
||||
|
||||
const sent = await deps.events.sendUserMessage({
|
||||
spaceId: bot.spaceId,
|
||||
threadId: bot.thread.id,
|
||||
botId: bot.id,
|
||||
userId: bot.userId,
|
||||
blocks: [{ kind: "text", text: promptText }],
|
||||
prompt: promptText,
|
||||
trigger: "webhook",
|
||||
clientNonce,
|
||||
});
|
||||
|
||||
if (sent.runId) {
|
||||
await deps.jobs.enqueue(runContinueJob(sent.runId)).catch((error) => {
|
||||
console.error("webhook run enqueue error", error);
|
||||
});
|
||||
}
|
||||
|
||||
return c.json({ ok: true, messageId: sent.messageId, runId: sent.runId, seq: sent.seq });
|
||||
});
|
||||
}
|
||||
|
|
@ -0,0 +1,9 @@
|
|||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "src",
|
||||
"noEmit": true
|
||||
},
|
||||
"include": ["src"],
|
||||
"exclude": ["src/**/*.test.ts"]
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 722 KiB |
Binary file not shown.
Binary file not shown.
|
After Width: | Height: | Size: 56 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 880 KiB |
|
|
@ -0,0 +1,15 @@
|
|||
import { defineConfig } from "@playwright/test";
|
||||
|
||||
export default defineConfig({
|
||||
testDir: ".",
|
||||
testMatch: "*.spec.ts",
|
||||
fullyParallel: false,
|
||||
workers: 1,
|
||||
timeout: 30_000,
|
||||
expect: { timeout: 5_000 },
|
||||
reporter: "list",
|
||||
use: {
|
||||
screenshot: "only-on-failure",
|
||||
trace: "retain-on-failure",
|
||||
},
|
||||
});
|
||||
|
|
@ -0,0 +1,541 @@
|
|||
import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
||||
import { createServer, type Server } from "node:http";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import { type ElectronApplication, _electron as electron, expect, test } from "@playwright/test";
|
||||
|
||||
const APP_MARKER = "Existing BangSo Bot instance ready";
|
||||
|
||||
let server: Server;
|
||||
let serverUrl: string;
|
||||
let closedUrl: string;
|
||||
let userData: string;
|
||||
let app: ElectronApplication | undefined;
|
||||
|
||||
/** A port nothing listens on, so a connection attempt is refused rather than blocked. */
|
||||
async function reserveClosedPort() {
|
||||
const probe = createServer();
|
||||
await new Promise<void>((resolve) => probe.listen(0, "127.0.0.1", resolve));
|
||||
const address = probe.address();
|
||||
if (address === null || typeof address === "string") throw new Error("probe has no port");
|
||||
const { port } = address;
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
probe.close((error) => (error ? reject(error) : resolve()));
|
||||
});
|
||||
return `http://127.0.0.1:${port}`;
|
||||
}
|
||||
|
||||
test.beforeAll(async () => {
|
||||
server = createServer((request, response) => {
|
||||
if (request.url === "/rpc/health" && request.method === "POST") {
|
||||
response.writeHead(200, { "content-type": "application/json; charset=utf-8" });
|
||||
response.end(JSON.stringify({ json: { ok: true, version: "0.1.0" } }));
|
||||
return;
|
||||
}
|
||||
response.writeHead(200, { "content-type": "text/html; charset=utf-8" });
|
||||
response.end(
|
||||
`<!doctype html><html lang="en"><head><meta charset="utf-8"><title>BangSo Bot</title></head><body><main>${APP_MARKER}</main></body></html>`,
|
||||
);
|
||||
});
|
||||
await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve));
|
||||
const address = server.address();
|
||||
if (address === null || typeof address === "string") throw new Error("stub server has no port");
|
||||
serverUrl = `http://127.0.0.1:${address.port}`;
|
||||
closedUrl = await reserveClosedPort();
|
||||
});
|
||||
|
||||
test.afterAll(async () => {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
server.close((error) => (error ? reject(error) : resolve()));
|
||||
});
|
||||
});
|
||||
|
||||
test.beforeEach(async () => {
|
||||
userData = await mkdtemp(path.join(tmpdir(), "rakazo-desktop-e2e-"));
|
||||
});
|
||||
|
||||
test.afterEach(async () => {
|
||||
await app?.close();
|
||||
app = undefined;
|
||||
await rm(userData, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
function launch(extraEnv: Record<string, string> = {}) {
|
||||
const env = { ...process.env, RAKAZO_PERFORMANCE_USER_DATA: userData };
|
||||
// A stale RAKAZO_WEB_URL from the developer's shell would bypass setup entirely.
|
||||
delete env.RAKAZO_WEB_URL;
|
||||
return electron.launch({
|
||||
args: ["."],
|
||||
cwd: path.resolve(import.meta.dirname, ".."),
|
||||
env: { ...env, ...extraEnv },
|
||||
});
|
||||
}
|
||||
|
||||
test("first run asks whether to use a local or existing instance", async () => {
|
||||
app = await launch();
|
||||
const setup = await app.firstWindow();
|
||||
|
||||
await expect(setup.getByRole("heading", { name: "Welcome to BangSo Bot" })).toBeVisible();
|
||||
await expect(setup.getByText("This computer")).toBeVisible();
|
||||
await expect(setup.getByText("Existing instance")).toBeVisible();
|
||||
|
||||
// A new instance is the default and points at the local development stack.
|
||||
await expect(setup.getByRole("radio", { name: /This computer/ })).toBeChecked();
|
||||
await expect(setup.locator("#local-url")).toHaveValue("http://127.0.0.1:5173");
|
||||
await expect(setup.locator("#panel-existing")).toBeHidden();
|
||||
|
||||
await setup.screenshot({
|
||||
path: path.join(import.meta.dirname, "screenshots", "01-setup-new-instance.png"),
|
||||
});
|
||||
});
|
||||
|
||||
test("connecting to an existing instance verifies, saves, and opens it", async () => {
|
||||
app = await launch();
|
||||
const setup = await app.firstWindow();
|
||||
|
||||
await setup.getByRole("radio", { name: /Existing instance/ }).check();
|
||||
await expect(setup.locator("#panel-new")).toBeHidden();
|
||||
|
||||
await setup.locator("#server-url").fill(serverUrl);
|
||||
await setup.getByRole("button", { name: "Check connection" }).click();
|
||||
await expect(setup.locator("#status")).toHaveText(`BangSo Bot answered at ${serverUrl}.`);
|
||||
await expect(setup.locator("#status")).toHaveAttribute("data-tone", "ok");
|
||||
|
||||
await setup.screenshot({
|
||||
path: path.join(import.meta.dirname, "screenshots", "02-setup-existing-verified.png"),
|
||||
});
|
||||
|
||||
const appWindow = await Promise.all([
|
||||
app.waitForEvent("window"),
|
||||
setup.getByRole("button", { name: "Continue" }).click(),
|
||||
]).then(([window]) => window);
|
||||
|
||||
await expect(appWindow.getByText(APP_MARKER)).toBeVisible();
|
||||
await appWindow.screenshot({
|
||||
path: path.join(import.meta.dirname, "screenshots", "03-connected-instance.png"),
|
||||
});
|
||||
|
||||
// Continue can paint the app window before setup.json finishes flushing to disk.
|
||||
await expect
|
||||
.poll(async () => {
|
||||
try {
|
||||
return JSON.parse(await readFile(path.join(userData, "setup.json"), "utf8"));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
})
|
||||
.toEqual({ mode: "existing", serverUrl });
|
||||
});
|
||||
|
||||
test("Continue verifies and remembers the instance so setup does not run again", async () => {
|
||||
app = await launch();
|
||||
const setup = await app.firstWindow();
|
||||
await setup.getByRole("radio", { name: /Existing instance/ }).check();
|
||||
await setup.locator("#server-url").fill(serverUrl);
|
||||
const firstRun = await Promise.all([
|
||||
app.waitForEvent("window"),
|
||||
setup.getByRole("button", { name: "Continue" }).click(),
|
||||
]).then(([window]) => window);
|
||||
await expect(firstRun.getByText(APP_MARKER)).toBeVisible();
|
||||
// Continue can paint the app window before setup.json finishes flushing to disk.
|
||||
await expect
|
||||
.poll(async () => {
|
||||
try {
|
||||
return JSON.parse(await readFile(path.join(userData, "setup.json"), "utf8"));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
})
|
||||
.toEqual({ mode: "existing", serverUrl });
|
||||
await app.close();
|
||||
|
||||
app = await launch();
|
||||
const relaunched = await app.firstWindow();
|
||||
await expect(relaunched.getByText(APP_MARKER)).toBeVisible();
|
||||
await expect(relaunched.locator("#setup")).toHaveCount(0);
|
||||
});
|
||||
|
||||
test("an unreachable address is reported instead of being saved", async () => {
|
||||
app = await launch();
|
||||
const setup = await app.firstWindow();
|
||||
|
||||
await setup.getByRole("radio", { name: /Existing instance/ }).check();
|
||||
await setup.locator("#server-url").fill(closedUrl);
|
||||
await setup.getByRole("button", { name: "Check connection" }).click();
|
||||
|
||||
await expect(setup.locator("#status")).toHaveAttribute("data-tone", "error");
|
||||
await expect(setup.locator("#status")).toHaveText("Nothing is listening at that address yet.");
|
||||
await setup.getByRole("button", { name: "Continue" }).click();
|
||||
await expect(setup.locator("#status")).toHaveText("Nothing is listening at that address yet.");
|
||||
await expect(async () => {
|
||||
await expect(readFile(path.join(userData, "setup.json"), "utf8")).rejects.toThrow();
|
||||
}).toPass();
|
||||
await setup.screenshot({
|
||||
path: path.join(import.meta.dirname, "screenshots", "04-setup-unreachable.png"),
|
||||
});
|
||||
});
|
||||
|
||||
test("an HTTP error document is not accepted after a healthy probe", async () => {
|
||||
const broken = createServer((request, response) => {
|
||||
if (request.url === "/rpc/health" && request.method === "POST") {
|
||||
response.writeHead(200, { "content-type": "application/json; charset=utf-8" });
|
||||
response.end(JSON.stringify({ json: { ok: true, version: "0.1.0" } }));
|
||||
return;
|
||||
}
|
||||
response.writeHead(503, { "content-type": "text/html; charset=utf-8" });
|
||||
response.end("<!doctype html><html><body><h1>Unavailable</h1></body></html>");
|
||||
});
|
||||
await new Promise<void>((resolve) => broken.listen(0, "127.0.0.1", resolve));
|
||||
const address = broken.address();
|
||||
if (address === null || typeof address === "string") throw new Error("broken server has no port");
|
||||
|
||||
try {
|
||||
app = await launch();
|
||||
const setup = await app.firstWindow();
|
||||
await setup.getByRole("radio", { name: /Existing instance/ }).check();
|
||||
await setup.locator("#server-url").fill(`http://127.0.0.1:${address.port}`);
|
||||
await setup.getByRole("button", { name: "Continue" }).click();
|
||||
|
||||
await expect(setup.locator("#status")).toContainText("Could not open that server.");
|
||||
await expect(async () => {
|
||||
await expect(readFile(path.join(userData, "setup.json"), "utf8")).rejects.toThrow();
|
||||
}).toPass();
|
||||
} finally {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
broken.close((error) => (error ? reject(error) : resolve()));
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
test("a session-pending shell skeleton is not accepted as a ready app", async () => {
|
||||
const skeletonHtml = `<!doctype html><html lang="en"><head><meta charset="utf-8"><title>BangSo Bot</title></head>
|
||||
<body><div id="root"><div data-rakazo-app-state="session-pending"><aside></aside><main><div>Opening your Space…</div></main></div></div></body></html>`;
|
||||
const skeleton = createServer((request, response) => {
|
||||
if (request.url === "/rpc/health" && request.method === "POST") {
|
||||
response.writeHead(200, { "content-type": "application/json; charset=utf-8" });
|
||||
response.end(JSON.stringify({ json: { ok: true, version: "0.1.0" } }));
|
||||
return;
|
||||
}
|
||||
response.writeHead(200, { "content-type": "text/html; charset=utf-8" });
|
||||
response.end(skeletonHtml);
|
||||
});
|
||||
await new Promise<void>((resolve) => skeleton.listen(0, "127.0.0.1", resolve));
|
||||
const address = skeleton.address();
|
||||
if (address === null || typeof address === "string")
|
||||
throw new Error("skeleton server has no port");
|
||||
|
||||
try {
|
||||
app = await launch();
|
||||
const setup = await app.firstWindow();
|
||||
await setup.getByRole("radio", { name: /Existing instance/ }).check();
|
||||
await setup.locator("#server-url").fill(`http://127.0.0.1:${address.port}`);
|
||||
await setup.getByRole("button", { name: "Continue" }).click();
|
||||
|
||||
await expect(setup.locator("#status")).toContainText("Could not open that server.", {
|
||||
timeout: 15_000,
|
||||
});
|
||||
await expect(async () => {
|
||||
await expect(readFile(path.join(userData, "setup.json"), "utf8")).rejects.toThrow();
|
||||
}).toPass();
|
||||
} finally {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
skeleton.close((error) => (error ? reject(error) : resolve()));
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
test("a post-session ready app mount is accepted", async () => {
|
||||
const readyHtml = `<!doctype html><html lang="en"><head><meta charset="utf-8"><title>BangSo Bot</title>
|
||||
<script>performance.mark("rk:renderer:session-committed");performance.mark("rk:renderer:shell-ready");</script>
|
||||
</head>
|
||||
<body><div id="root"><div data-rakazo-app-state="ready"><div data-testid="shell-root" data-ready="true">Workspace</div></div></div></body></html>`;
|
||||
const ready = createServer((request, response) => {
|
||||
if (request.url === "/rpc/health" && request.method === "POST") {
|
||||
response.writeHead(200, { "content-type": "application/json; charset=utf-8" });
|
||||
response.end(JSON.stringify({ json: { ok: true, version: "0.1.0" } }));
|
||||
return;
|
||||
}
|
||||
response.writeHead(200, { "content-type": "text/html; charset=utf-8" });
|
||||
response.end(readyHtml);
|
||||
});
|
||||
await new Promise<void>((resolve) => ready.listen(0, "127.0.0.1", resolve));
|
||||
const address = ready.address();
|
||||
if (address === null || typeof address === "string") throw new Error("ready server has no port");
|
||||
|
||||
try {
|
||||
app = await launch();
|
||||
const setup = await app.firstWindow();
|
||||
await setup.getByRole("radio", { name: /Existing instance/ }).check();
|
||||
await setup.locator("#server-url").fill(`http://127.0.0.1:${address.port}`);
|
||||
const appWindow = await Promise.all([
|
||||
app.waitForEvent("window"),
|
||||
setup.getByRole("button", { name: "Continue" }).click(),
|
||||
]).then(([window]) => window);
|
||||
|
||||
await expect(appWindow.getByTestId("shell-root")).toBeVisible();
|
||||
await expect
|
||||
.poll(async () => {
|
||||
try {
|
||||
return JSON.parse(await readFile(path.join(userData, "setup.json"), "utf8"));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
})
|
||||
.toEqual({
|
||||
mode: "existing",
|
||||
serverUrl: `http://127.0.0.1:${address.port}`,
|
||||
});
|
||||
} finally {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
ready.close((error) => (error ? reject(error) : resolve()));
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
test("a shell mount before workspace bootstrap is not accepted", async () => {
|
||||
const preBootstrapHtml = `<!doctype html><html lang="en"><head><meta charset="utf-8"><title>BangSo Bot</title></head>
|
||||
<body><div id="root"><div data-rakazo-app-state="ready"><div data-testid="shell-root" data-ready="false">Workspace</div></div></div></body></html>`;
|
||||
const preBootstrap = createServer((request, response) => {
|
||||
if (request.url === "/rpc/health" && request.method === "POST") {
|
||||
response.writeHead(200, { "content-type": "application/json; charset=utf-8" });
|
||||
response.end(JSON.stringify({ json: { ok: true, version: "0.1.0" } }));
|
||||
return;
|
||||
}
|
||||
response.writeHead(200, { "content-type": "text/html; charset=utf-8" });
|
||||
response.end(preBootstrapHtml);
|
||||
});
|
||||
await new Promise<void>((resolve) => preBootstrap.listen(0, "127.0.0.1", resolve));
|
||||
const address = preBootstrap.address();
|
||||
if (address === null || typeof address === "string")
|
||||
throw new Error("pre-bootstrap server has no port");
|
||||
|
||||
try {
|
||||
app = await launch();
|
||||
const setup = await app.firstWindow();
|
||||
await setup.getByRole("radio", { name: /Existing instance/ }).check();
|
||||
await setup.locator("#server-url").fill(`http://127.0.0.1:${address.port}`);
|
||||
await setup.getByRole("button", { name: "Continue" }).click();
|
||||
|
||||
await expect(setup.locator("#status")).toContainText("Could not open that server.", {
|
||||
timeout: 15_000,
|
||||
});
|
||||
await expect(async () => {
|
||||
await expect(readFile(path.join(userData, "setup.json"), "utf8")).rejects.toThrow();
|
||||
}).toPass();
|
||||
} finally {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
preBootstrap.close((error) => (error ? reject(error) : resolve()));
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
test("a session-ready marker without a route surface is not accepted", async () => {
|
||||
const emptyReadyHtml = `<!doctype html><html lang="en"><head><meta charset="utf-8"><title>BangSo Bot</title></head>
|
||||
<body><div id="root"><div data-rakazo-app-state="ready" class="h-full"></div></div></body></html>`;
|
||||
const emptyReady = createServer((request, response) => {
|
||||
if (request.url === "/rpc/health" && request.method === "POST") {
|
||||
response.writeHead(200, { "content-type": "application/json; charset=utf-8" });
|
||||
response.end(JSON.stringify({ json: { ok: true, version: "0.1.0" } }));
|
||||
return;
|
||||
}
|
||||
response.writeHead(200, { "content-type": "text/html; charset=utf-8" });
|
||||
response.end(emptyReadyHtml);
|
||||
});
|
||||
await new Promise<void>((resolve) => emptyReady.listen(0, "127.0.0.1", resolve));
|
||||
const address = emptyReady.address();
|
||||
if (address === null || typeof address === "string")
|
||||
throw new Error("empty-ready server has no port");
|
||||
|
||||
try {
|
||||
app = await launch();
|
||||
const setup = await app.firstWindow();
|
||||
await setup.getByRole("radio", { name: /Existing instance/ }).check();
|
||||
await setup.locator("#server-url").fill(`http://127.0.0.1:${address.port}`);
|
||||
await setup.getByRole("button", { name: "Continue" }).click();
|
||||
|
||||
await expect(setup.locator("#status")).toContainText("Could not open that server.", {
|
||||
timeout: 15_000,
|
||||
});
|
||||
await expect(async () => {
|
||||
await expect(readFile(path.join(userData, "setup.json"), "utf8")).rejects.toThrow();
|
||||
}).toPass();
|
||||
} finally {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
emptyReady.close((error) => (error ? reject(error) : resolve()));
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
test("a malformed address is rejected before anything is written", async () => {
|
||||
app = await launch();
|
||||
const setup = await app.firstWindow();
|
||||
|
||||
await setup.getByRole("radio", { name: /Existing instance/ }).check();
|
||||
await setup.locator("#server-url").fill("not a server");
|
||||
await setup.getByRole("button", { name: "Continue" }).click();
|
||||
|
||||
await expect(setup.locator("#status")).toContainText("Enter a valid server address.");
|
||||
await expect(async () => {
|
||||
await expect(readFile(path.join(userData, "setup.json"), "utf8")).rejects.toThrow();
|
||||
}).toPass();
|
||||
});
|
||||
|
||||
test("a generic web page is not accepted as a BangSo Bot server", async () => {
|
||||
const plain = createServer((_request, response) => {
|
||||
response.writeHead(200, { "content-type": "text/html; charset=utf-8" });
|
||||
response.end("<!doctype html><p>not BangSo Bot</p>");
|
||||
});
|
||||
await new Promise<void>((resolve) => plain.listen(0, "127.0.0.1", resolve));
|
||||
const address = plain.address();
|
||||
if (address === null || typeof address === "string") throw new Error("plain server has no port");
|
||||
|
||||
try {
|
||||
app = await launch();
|
||||
const setup = await app.firstWindow();
|
||||
await setup.getByRole("radio", { name: /Existing instance/ }).check();
|
||||
await setup.locator("#server-url").fill(`http://127.0.0.1:${address.port}`);
|
||||
await setup.getByRole("button", { name: "Continue" }).click();
|
||||
|
||||
await expect(setup.locator("#status")).toHaveText(
|
||||
"That address did not respond like a BangSo Bot server.",
|
||||
);
|
||||
await expect(async () => {
|
||||
await expect(readFile(path.join(userData, "setup.json"), "utf8")).rejects.toThrow();
|
||||
}).toPass();
|
||||
} finally {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
plain.close((error) => (error ? reject(error) : resolve()));
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
test("the setup probe refuses redirects instead of following them", async () => {
|
||||
const redirect = createServer((_request, response) => {
|
||||
response.writeHead(302, { location: `${serverUrl}/rpc/health` });
|
||||
response.end();
|
||||
});
|
||||
await new Promise<void>((resolve) => redirect.listen(0, "127.0.0.1", resolve));
|
||||
const address = redirect.address();
|
||||
if (address === null || typeof address === "string") {
|
||||
throw new Error("redirect server has no port");
|
||||
}
|
||||
|
||||
try {
|
||||
app = await launch();
|
||||
const setup = await app.firstWindow();
|
||||
await setup.getByRole("radio", { name: /Existing instance/ }).check();
|
||||
await setup.locator("#server-url").fill(`http://127.0.0.1:${address.port}`);
|
||||
await setup.getByRole("button", { name: "Continue" }).click();
|
||||
|
||||
// Electron's net.fetch with redirect:"manual" surfaces redirects as a
|
||||
// network failure rather than an HTTP 3xx body we can classify.
|
||||
await expect(setup.locator("#status")).toHaveText("Could not reach that address.");
|
||||
await expect(async () => {
|
||||
await expect(readFile(path.join(userData, "setup.json"), "utf8")).rejects.toThrow();
|
||||
}).toPass();
|
||||
} finally {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
redirect.close((error) => (error ? reject(error) : resolve()));
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
test("an unreachable saved server falls back to setup with a recovery message", async () => {
|
||||
await writeFile(
|
||||
path.join(userData, "setup.json"),
|
||||
`${JSON.stringify({ mode: "existing", serverUrl: closedUrl }, null, 2)}\n`,
|
||||
{ encoding: "utf8", mode: 0o600 },
|
||||
);
|
||||
|
||||
app = await launch();
|
||||
const setup = await app.firstWindow();
|
||||
|
||||
await expect(setup.getByRole("heading", { name: "Welcome to BangSo Bot" })).toBeVisible();
|
||||
await expect(setup.getByRole("radio", { name: /Existing instance/ })).toBeChecked();
|
||||
await expect(setup.locator("#server-url")).toHaveValue(closedUrl);
|
||||
await expect(setup.locator("#status")).toContainText("Could not reconnect to the saved server.");
|
||||
await setup.screenshot({
|
||||
path: path.join(import.meta.dirname, "screenshots", "05-saved-server-recovery.png"),
|
||||
});
|
||||
});
|
||||
|
||||
test("the native application menu can reopen setup without exposing setup IPC to the server", async () => {
|
||||
app = await launch({ RAKAZO_WEB_URL: serverUrl });
|
||||
const appWindow = await app.firstWindow();
|
||||
await expect(appWindow.getByText(APP_MARKER)).toBeVisible();
|
||||
|
||||
const setupPromise = app.waitForEvent("window");
|
||||
await app.evaluate(({ Menu }) => {
|
||||
const item = Menu.getApplicationMenu()?.getMenuItemById("change-rakazo-server");
|
||||
if (!item) throw new Error("Change server menu item is missing");
|
||||
item.click();
|
||||
});
|
||||
const setup = await setupPromise;
|
||||
|
||||
await expect(setup.getByRole("heading", { name: "Welcome to BangSo Bot" })).toBeVisible();
|
||||
await expect(setup.locator("#status")).toBeEmpty();
|
||||
|
||||
// Closing setup without saving restores the connected instance.
|
||||
await setup.close();
|
||||
await expect(appWindow.getByText(APP_MARKER)).toBeVisible();
|
||||
});
|
||||
|
||||
test("servers on the same host but different ports do not share login cookies", async () => {
|
||||
const first = createServer((_request, response) => {
|
||||
response.writeHead(200, {
|
||||
"content-type": "text/html; charset=utf-8",
|
||||
"set-cookie": "rakazo_session=fake-one; Path=/; SameSite=Lax",
|
||||
});
|
||||
response.end("<!doctype html><main>Cookie stored</main>");
|
||||
});
|
||||
const second = createServer((request, response) => {
|
||||
response.writeHead(200, { "content-type": "text/html; charset=utf-8" });
|
||||
response.end(`<!doctype html><main>Cookies: ${request.headers.cookie ?? "none"}</main>`);
|
||||
});
|
||||
await Promise.all([
|
||||
new Promise<void>((resolve) => first.listen(0, "127.0.0.1", resolve)),
|
||||
new Promise<void>((resolve) => second.listen(0, "127.0.0.1", resolve)),
|
||||
]);
|
||||
const firstAddress = first.address();
|
||||
const secondAddress = second.address();
|
||||
if (
|
||||
firstAddress === null ||
|
||||
typeof firstAddress === "string" ||
|
||||
secondAddress === null ||
|
||||
typeof secondAddress === "string"
|
||||
) {
|
||||
throw new Error("cookie fixture server has no port");
|
||||
}
|
||||
|
||||
try {
|
||||
app = await launch({ RAKAZO_WEB_URL: `http://127.0.0.1:${firstAddress.port}` });
|
||||
const firstWindow = await app.firstWindow();
|
||||
await expect(firstWindow.getByText("Cookie stored")).toBeVisible();
|
||||
await expect.poll(() => firstWindow.evaluate(() => document.cookie)).toContain("fake-one");
|
||||
await app.close();
|
||||
|
||||
app = await launch({ RAKAZO_WEB_URL: `http://127.0.0.1:${secondAddress.port}` });
|
||||
const secondWindow = await app.firstWindow();
|
||||
await expect(secondWindow.getByText("Cookies: none")).toBeVisible();
|
||||
} finally {
|
||||
await Promise.all([
|
||||
new Promise<void>((resolve, reject) =>
|
||||
first.close((error) => (error ? reject(error) : resolve())),
|
||||
),
|
||||
new Promise<void>((resolve, reject) =>
|
||||
second.close((error) => (error ? reject(error) : resolve())),
|
||||
),
|
||||
]);
|
||||
}
|
||||
});
|
||||
|
||||
test("setup IPC is not reachable from the connected app window", async () => {
|
||||
app = await launch({ RAKAZO_WEB_URL: serverUrl });
|
||||
const appWindow = await app.firstWindow();
|
||||
await expect(appWindow.getByText(APP_MARKER)).toBeVisible();
|
||||
|
||||
const exposed = await appWindow.evaluate(() =>
|
||||
Object.keys((window as typeof window & { rakazoSetup?: unknown }).rakazoSetup ?? {}),
|
||||
);
|
||||
expect(exposed).toEqual([]);
|
||||
});
|
||||
|
|
@ -0,0 +1,82 @@
|
|||
import path from "node:path";
|
||||
import { _electron as electron, expect, test } from "@playwright/test";
|
||||
import type { RakazoDesktop } from "@rakazo/contracts";
|
||||
|
||||
const fixture = `<!doctype html>
|
||||
<html lang="en">
|
||||
<head><meta charset="utf-8"><title>Rakazo desktop smoke</title></head>
|
||||
<body><main>Desktop fixture ready</main></body>
|
||||
</html>`;
|
||||
|
||||
test("launches with a narrow preload bridge and an isolated renderer", async () => {
|
||||
const app = await electron.launch({
|
||||
args: ["."],
|
||||
cwd: path.resolve(import.meta.dirname, ".."),
|
||||
env: {
|
||||
...process.env,
|
||||
RAKAZO_WEB_URL: `data:text/html;charset=utf-8,${encodeURIComponent(fixture)}`,
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
const page = await app.firstWindow();
|
||||
await expect(page.getByText("Desktop fixture ready")).toBeVisible();
|
||||
await expect(page).toHaveTitle("Rakazo desktop smoke");
|
||||
|
||||
const renderer = await page.evaluate(async () => {
|
||||
const desktop = (window as typeof window & { rakazoDesktop?: RakazoDesktop }).rakazoDesktop;
|
||||
|
||||
return {
|
||||
bridgeKeys: desktop ? Object.keys(desktop).sort() : [],
|
||||
windowKeys: desktop ? Object.keys(desktop.window).sort() : [],
|
||||
updateKeys: desktop ? Object.keys(desktop.update).sort() : [],
|
||||
platform: desktop?.platform,
|
||||
state: await desktop?.window.state(),
|
||||
update: await desktop?.update.state(),
|
||||
nodeGlobals: {
|
||||
require: typeof (window as unknown as { require?: unknown }).require,
|
||||
process: typeof (window as unknown as { process?: unknown }).process,
|
||||
module: typeof (window as unknown as { module?: unknown }).module,
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
expect(renderer.bridgeKeys).toEqual(["oauth", "platform", "update", "window"]);
|
||||
expect(renderer.windowKeys).toEqual(["close", "minimize", "state", "toggleMaximize"]);
|
||||
expect(renderer.updateKeys).toEqual(["check", "download", "install", "state"]);
|
||||
expect(renderer.platform).toBe(process.platform);
|
||||
expect(renderer.state).toEqual({ minimized: false, maximized: false, fullScreen: false });
|
||||
// An unpackaged run has no update feed, and that is reported as a state rather than an error.
|
||||
expect(renderer.update).toMatchObject({ phase: "unsupported", availableVersion: null });
|
||||
expect(renderer.nodeGlobals).toEqual({
|
||||
require: "undefined",
|
||||
process: "undefined",
|
||||
module: "undefined",
|
||||
});
|
||||
|
||||
const preferences = await app.evaluate(({ BrowserWindow }) => {
|
||||
const win = BrowserWindow.getAllWindows()[0];
|
||||
return {
|
||||
count: BrowserWindow.getAllWindows().length,
|
||||
nodeIntegration: win?.webContents.getLastWebPreferences().nodeIntegration,
|
||||
contextIsolation: win?.webContents.getLastWebPreferences().contextIsolation,
|
||||
sandbox: win?.webContents.getLastWebPreferences().sandbox,
|
||||
state: {
|
||||
minimized: win?.isMinimized(),
|
||||
maximized: win?.isMaximized(),
|
||||
fullScreen: win?.isFullScreen(),
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
expect(preferences).toEqual({
|
||||
count: 1,
|
||||
nodeIntegration: false,
|
||||
contextIsolation: true,
|
||||
sandbox: true,
|
||||
state: renderer.state,
|
||||
});
|
||||
} finally {
|
||||
await app.close();
|
||||
}
|
||||
});
|
||||
|
|
@ -0,0 +1,70 @@
|
|||
import { readFileSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
import { _electron as electron, expect, test } from "@playwright/test";
|
||||
|
||||
const styles = readFileSync(path.resolve(import.meta.dirname, "../../web/src/styles.css"), "utf8");
|
||||
const fixture = `<!doctype html>
|
||||
<html lang="en">
|
||||
<head><meta charset="utf-8"><title>Rakazo window drag</title><style>${styles}</style></head>
|
||||
<body>
|
||||
<main>Desktop fixture ready</main>
|
||||
<header class="app-drag" id="conversation-header">
|
||||
<span>Chief</span>
|
||||
<button class="app-no-drag" id="bot-settings">Bot settings</button>
|
||||
</header>
|
||||
<output id="result"></output>
|
||||
<script>
|
||||
document.querySelector('#bot-settings').addEventListener('click', () => {
|
||||
document.querySelector('#result').textContent = 'opened';
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>`;
|
||||
|
||||
test("an active Electron window keeps header dragging selection-free and controls clickable", async () => {
|
||||
const app = await electron.launch({
|
||||
args: ["."],
|
||||
cwd: path.resolve(import.meta.dirname, ".."),
|
||||
env: {
|
||||
...process.env,
|
||||
RAKAZO_WEB_URL: `data:text/html;charset=utf-8,${encodeURIComponent(fixture)}`,
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
const page = await app.firstWindow();
|
||||
const active = await app.evaluate(({ BrowserWindow }) => {
|
||||
const window = BrowserWindow.getAllWindows()[0];
|
||||
window?.show();
|
||||
window?.focus();
|
||||
return window?.isFocused();
|
||||
});
|
||||
expect(active).toBe(true);
|
||||
|
||||
const regions = await page.evaluate(() => {
|
||||
const header = document.querySelector("#conversation-header");
|
||||
const settings = document.querySelector("#bot-settings");
|
||||
if (!(header instanceof HTMLElement) || !(settings instanceof HTMLElement)) {
|
||||
throw new Error("missing window chrome fixture");
|
||||
}
|
||||
const headerStyle = getComputedStyle(header);
|
||||
const settingsStyle = getComputedStyle(settings);
|
||||
return {
|
||||
header: {
|
||||
appRegion: headerStyle.getPropertyValue("-webkit-app-region"),
|
||||
userSelect: headerStyle.userSelect,
|
||||
},
|
||||
settings: settingsStyle.getPropertyValue("-webkit-app-region"),
|
||||
};
|
||||
});
|
||||
|
||||
expect(regions).toEqual({
|
||||
header: { appRegion: "drag", userSelect: "none" },
|
||||
settings: "no-drag",
|
||||
});
|
||||
await page.getByRole("button", { name: "Bot settings" }).click();
|
||||
await expect(page.locator("#result")).toHaveText("opened");
|
||||
} finally {
|
||||
await app.close();
|
||||
}
|
||||
});
|
||||
|
|
@ -0,0 +1,78 @@
|
|||
{
|
||||
"name": "@rakazo/desktop",
|
||||
"version": "0.1.0",
|
||||
"license": "Apache-2.0",
|
||||
"type": "module",
|
||||
"main": "dist/main.js",
|
||||
"scripts": {
|
||||
"dev": "pnpm build && electron .",
|
||||
"build": "tsc -p tsconfig.json && node scripts/copy-static.mjs",
|
||||
"pack": "pnpm --filter @rakazo/web build && pnpm build && electron-builder --mac --win --linux",
|
||||
"pack:dir": "pnpm --filter @rakazo/web build && pnpm build && electron-builder --dir",
|
||||
"check": "tsc --noEmit -p tsconfig.json",
|
||||
"test": "vitest run --root ../.. apps/desktop/src",
|
||||
"test:e2e": "pnpm build && playwright test --config e2e/playwright.config.ts",
|
||||
"release": "pnpm --filter @rakazo/web build && pnpm build && electron-builder --publish never"
|
||||
},
|
||||
"dependencies": {
|
||||
"@rakazo/contracts": "workspace:*",
|
||||
"electron-updater": "^6.8.9"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@playwright/test": "^1.55.0",
|
||||
"electron": "^43.4.0",
|
||||
"electron-builder": "^26.0.12",
|
||||
"typescript": "^5.9.2",
|
||||
"vitest": "^4.1.10"
|
||||
},
|
||||
"build": {
|
||||
"appId": "dev.rakazo.desktop",
|
||||
"productName": "BangSo Bot",
|
||||
"directories": {
|
||||
"output": "out",
|
||||
"buildResources": "assets"
|
||||
},
|
||||
"files": [
|
||||
"dist/**/*",
|
||||
"package.json"
|
||||
],
|
||||
"extraResources": [
|
||||
{
|
||||
"from": "../web/dist",
|
||||
"to": "web"
|
||||
}
|
||||
],
|
||||
"extraMetadata": {
|
||||
"main": "dist/main.js"
|
||||
},
|
||||
"mac": {
|
||||
"icon": "assets/icon.icns",
|
||||
"category": "public.app-category.productivity",
|
||||
"target": [
|
||||
"dmg",
|
||||
"zip"
|
||||
],
|
||||
"notarize": true
|
||||
},
|
||||
"win": {
|
||||
"icon": "assets/icon.ico",
|
||||
"target": [
|
||||
"nsis"
|
||||
],
|
||||
"verifyUpdateCodeSignature": true
|
||||
},
|
||||
"linux": {
|
||||
"icon": "assets/icon.png",
|
||||
"target": [
|
||||
"AppImage"
|
||||
]
|
||||
},
|
||||
"publish": [
|
||||
{
|
||||
"provider": "github",
|
||||
"owner": "elie222",
|
||||
"repo": "rakazo"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,15 @@
|
|||
import { copyFile, mkdir } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
// tsc only emits the TypeScript sources; the preload bridges and the setup
|
||||
// window's static assets have to be copied into dist alongside them.
|
||||
const STATIC_FILES = ["preload.cjs", "setup-preload.cjs", "setup.html", "setup.css", "setup.js"];
|
||||
|
||||
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
||||
const dist = path.join(root, "dist");
|
||||
|
||||
await mkdir(dist, { recursive: true });
|
||||
await Promise.all(
|
||||
STATIC_FILES.map((file) => copyFile(path.join(root, "src", file), path.join(dist, file))),
|
||||
);
|
||||
|
|
@ -0,0 +1,431 @@
|
|||
import { describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
classifyUpdaterFailure,
|
||||
DesktopUpdateController,
|
||||
type ElectronAutoUpdater,
|
||||
initialUpdateState,
|
||||
MIN_CHECK_INTERVAL_MS,
|
||||
reduceUpdateState,
|
||||
shouldCheck,
|
||||
type UpdaterEvent,
|
||||
updaterSupport,
|
||||
} from "./auto-update.js";
|
||||
|
||||
const NOW = "2026-08-22T12:00:00.000Z";
|
||||
const packaged = { packaged: true, version: "0.1.0" };
|
||||
const clock = { now: () => 1_000, iso: () => NOW };
|
||||
|
||||
function apply(events: UpdaterEvent[], env = packaged) {
|
||||
return events.reduce(
|
||||
(state, event) => reduceUpdateState(state, event, NOW),
|
||||
initialUpdateState(env),
|
||||
);
|
||||
}
|
||||
|
||||
function fakeUpdater(overrides: Partial<ElectronAutoUpdater> = {}) {
|
||||
const listeners = new Map<string, (payload: unknown) => void>();
|
||||
const updater: ElectronAutoUpdater = {
|
||||
autoDownload: true,
|
||||
autoInstallOnAppQuit: false,
|
||||
allowDowngrade: true,
|
||||
allowPrerelease: true,
|
||||
disableWebInstaller: false,
|
||||
on: vi.fn((event, listener) => {
|
||||
listeners.set(event, listener);
|
||||
return updater;
|
||||
}),
|
||||
checkForUpdates: vi.fn(async () => undefined),
|
||||
downloadUpdate: vi.fn(async () => undefined),
|
||||
quitAndInstall: vi.fn(),
|
||||
...overrides,
|
||||
};
|
||||
return {
|
||||
updater,
|
||||
emit(event: string, payload?: unknown) {
|
||||
listeners.get(event)?.(payload);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function deferred() {
|
||||
let resolve!: () => void;
|
||||
const promise = new Promise<void>((done) => {
|
||||
resolve = done;
|
||||
});
|
||||
return { promise, resolve };
|
||||
}
|
||||
|
||||
describe("updaterSupport", () => {
|
||||
it("only runs in an installed build that has not opted out", () => {
|
||||
expect(updaterSupport(packaged).supported).toBe(true);
|
||||
expect(updaterSupport({ packaged: false, version: "0.1.0" }).supported).toBe(false);
|
||||
expect(updaterSupport({ ...packaged, disabled: true }).supported).toBe(false);
|
||||
});
|
||||
|
||||
it("starts unsupported builds in a state that explains itself", () => {
|
||||
const state = initialUpdateState({ packaged: false, version: "0.1.0" });
|
||||
expect(state).toMatchObject({ phase: "unsupported", currentVersion: "0.1.0" });
|
||||
expect(state.message).toContain("installed build");
|
||||
});
|
||||
});
|
||||
|
||||
describe("classifyUpdaterFailure", () => {
|
||||
it("treats a repository with no releases as an absent feed, not a fault", () => {
|
||||
for (const message of [
|
||||
"HttpError: 404 Not Found",
|
||||
"Cannot find latest.yml in the latest release",
|
||||
"No published versions on GitHub",
|
||||
]) {
|
||||
expect(classifyUpdaterFailure(new Error(message)).kind, message).toBe("no-releases");
|
||||
}
|
||||
});
|
||||
|
||||
it("stays silent when the machine is simply offline", () => {
|
||||
for (const message of ["getaddrinfo ENOTFOUND github.com", "net::ERR_INTERNET_DISCONNECTED"]) {
|
||||
expect(classifyUpdaterFailure(new Error(message))).toEqual({
|
||||
kind: "offline",
|
||||
message: null,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it("surfaces signing failures without leaking the updater's raw URL or path", () => {
|
||||
const failure = classifyUpdaterFailure(
|
||||
new Error("Code signature at https://private.invalid/update.zip did not pass validation"),
|
||||
);
|
||||
expect(failure.kind).toBe("signature");
|
||||
expect(failure.message).toContain("could not be verified");
|
||||
expect(failure.message).not.toContain("private.invalid");
|
||||
});
|
||||
|
||||
it("keeps unclassified updater detail out of the renderer bridge", () => {
|
||||
const failure = classifyUpdaterFailure(new Error("disk full at /Users/example/private"));
|
||||
expect(failure).toEqual({
|
||||
kind: "other",
|
||||
message: "The update could not be completed. Try again later.",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("reduceUpdateState", () => {
|
||||
it("walks from a check to a downloaded release", () => {
|
||||
const state = apply([
|
||||
{ type: "check-start" },
|
||||
{ type: "available", version: "0.2.0" },
|
||||
{ type: "download-start" },
|
||||
{ type: "progress", percent: 42.6 },
|
||||
{ type: "downloaded", version: "0.2.0" },
|
||||
]);
|
||||
expect(state).toMatchObject({
|
||||
phase: "ready",
|
||||
availableVersion: "0.2.0",
|
||||
percent: 100,
|
||||
});
|
||||
expect(state.message).toContain("Restart BangSo Bot");
|
||||
});
|
||||
|
||||
it("clamps progress to a percentage while downloading", () => {
|
||||
expect(
|
||||
apply([
|
||||
{ type: "available", version: "0.2.0" },
|
||||
{ type: "progress", percent: -5 },
|
||||
]).percent,
|
||||
).toBe(0);
|
||||
expect(
|
||||
apply([
|
||||
{ type: "available", version: "0.2.0" },
|
||||
{ type: "download-start" },
|
||||
{ type: "progress", percent: 250 },
|
||||
]).percent,
|
||||
).toBe(100);
|
||||
});
|
||||
|
||||
it("ignores progress outside an active download", () => {
|
||||
expect(apply([{ type: "progress", percent: 40 }])).toMatchObject({
|
||||
phase: "idle",
|
||||
percent: null,
|
||||
});
|
||||
expect(
|
||||
apply([
|
||||
{ type: "check-start" },
|
||||
{ type: "failed", error: new Error("network down"), userInitiated: true },
|
||||
{ type: "progress", percent: 50 },
|
||||
]),
|
||||
).toMatchObject({ phase: "error", percent: null });
|
||||
});
|
||||
|
||||
it("clears an offer when a completed check finds nothing", () => {
|
||||
const state = apply([
|
||||
{ type: "available", version: "0.2.0" },
|
||||
{ type: "check-start" },
|
||||
{ type: "not-available" },
|
||||
]);
|
||||
expect(state).toMatchObject({ phase: "idle", availableVersion: null, checkedAt: NOW });
|
||||
});
|
||||
|
||||
it("goes quiet on a missing feed instead of nagging every launch", () => {
|
||||
const state = apply([
|
||||
{ type: "check-start" },
|
||||
{ type: "failed", error: new Error("HttpError: 404 Not Found"), userInitiated: false },
|
||||
]);
|
||||
expect(state.phase).toBe("unsupported");
|
||||
expect(state.message).toContain("No desktop releases");
|
||||
expect(reduceUpdateState(state, { type: "check-start" }, NOW)).toBe(state);
|
||||
expect(shouldCheck(state, 10_000_000, 0)).toBe(false);
|
||||
});
|
||||
|
||||
it("keeps a verified download when the updater emits a late failure", () => {
|
||||
const ready = apply([
|
||||
{ type: "downloaded", version: "0.2.0" },
|
||||
{ type: "failed", error: new Error("socket hang up"), userInitiated: false },
|
||||
]);
|
||||
expect(ready).toMatchObject({ phase: "ready", availableVersion: "0.2.0", percent: 100 });
|
||||
});
|
||||
|
||||
it("lets an install failure leave the ready phase", () => {
|
||||
const failed = apply([
|
||||
{ type: "downloaded", version: "0.2.0" },
|
||||
{
|
||||
type: "failed",
|
||||
error: new Error("quitAndInstall failed"),
|
||||
userInitiated: true,
|
||||
installFailed: true,
|
||||
},
|
||||
]);
|
||||
expect(failed).toMatchObject({
|
||||
phase: "error",
|
||||
availableVersion: "0.2.0",
|
||||
message: "The update could not be completed. Try again later.",
|
||||
});
|
||||
});
|
||||
|
||||
it("does not let a stray check-start interrupt an in-flight download", () => {
|
||||
const downloading = apply([
|
||||
{ type: "download-start" },
|
||||
{ type: "progress", percent: 40 },
|
||||
{ type: "check-start" },
|
||||
]);
|
||||
expect(downloading).toMatchObject({ phase: "downloading", percent: 40 });
|
||||
});
|
||||
|
||||
it("says nothing about being offline unless the user asked", () => {
|
||||
const offline = new Error("getaddrinfo ENOTFOUND github.com");
|
||||
expect(apply([{ type: "failed", error: offline, userInitiated: false }]).message).toBeNull();
|
||||
expect(apply([{ type: "failed", error: offline, userInitiated: true }]).message).toContain(
|
||||
"Could not reach",
|
||||
);
|
||||
});
|
||||
|
||||
it("reports other failures with a safe generic message", () => {
|
||||
const state = apply([{ type: "failed", error: new Error("disk full"), userInitiated: false }]);
|
||||
expect(state).toMatchObject({
|
||||
phase: "error",
|
||||
message: "The update could not be completed. Try again later.",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("shouldCheck", () => {
|
||||
it("allows the first check and then rate-limits", () => {
|
||||
const idle = initialUpdateState(packaged);
|
||||
expect(shouldCheck(idle, 1_000, 0)).toBe(true);
|
||||
expect(shouldCheck(idle, 1_000, 900)).toBe(false);
|
||||
expect(shouldCheck(idle, MIN_CHECK_INTERVAL_MS + 1_000, 1_000)).toBe(true);
|
||||
});
|
||||
|
||||
it("does not replace work, an available update, or a downloaded update", () => {
|
||||
for (const state of [
|
||||
apply([{ type: "check-start" }]),
|
||||
apply([{ type: "available", version: "0.2.0" }]),
|
||||
apply([{ type: "download-start" }]),
|
||||
apply([{ type: "downloaded", version: "0.2.0" }]),
|
||||
]) {
|
||||
expect(shouldCheck(state, 10_000_000, 0)).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
it("lets a manual check retry after an empty feed when the install supports updates", () => {
|
||||
const absent = apply([
|
||||
{ type: "check-start" },
|
||||
{ type: "failed", error: new Error("HttpError: 404 Not Found"), userInitiated: false },
|
||||
]);
|
||||
expect(
|
||||
shouldCheck(absent, 1_000, 1_000, {
|
||||
userInitiated: true,
|
||||
environmentSupportsUpdates: true,
|
||||
}),
|
||||
).toBe(true);
|
||||
expect(
|
||||
shouldCheck(absent, 1_000, 1_000, {
|
||||
userInitiated: true,
|
||||
environmentSupportsUpdates: false,
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("DesktopUpdateController", () => {
|
||||
it("locks the updater to stable, upgrade-only, signed installer behavior", async () => {
|
||||
const fake = fakeUpdater();
|
||||
const controller = new DesktopUpdateController(packaged, async () => fake.updater, clock);
|
||||
|
||||
await controller.check(false);
|
||||
|
||||
expect(fake.updater).toMatchObject({
|
||||
autoDownload: false,
|
||||
autoInstallOnAppQuit: true,
|
||||
allowPrerelease: false,
|
||||
allowDowngrade: false,
|
||||
disableWebInstaller: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("serializes concurrent checks across the asynchronous updater load", async () => {
|
||||
const check = deferred();
|
||||
const fake = fakeUpdater({ checkForUpdates: vi.fn(() => check.promise) });
|
||||
const controller = new DesktopUpdateController(packaged, async () => fake.updater, clock);
|
||||
|
||||
const first = controller.check(false);
|
||||
const second = controller.check(true);
|
||||
await vi.waitFor(() => expect(fake.updater.checkForUpdates).toHaveBeenCalledTimes(1));
|
||||
expect(second).toBe(first);
|
||||
check.resolve();
|
||||
await first;
|
||||
});
|
||||
|
||||
it("automatically downloads one verified stable update and tracks progress", async () => {
|
||||
let fake: ReturnType<typeof fakeUpdater>;
|
||||
fake = fakeUpdater({
|
||||
checkForUpdates: vi.fn(async () => {
|
||||
fake.emit("checking-for-update");
|
||||
fake.emit("update-available", { version: "0.2.0" });
|
||||
}),
|
||||
downloadUpdate: vi.fn(async () => {
|
||||
fake.emit("download-progress", { percent: 48.8 });
|
||||
fake.emit("update-downloaded", { version: "0.2.0" });
|
||||
}),
|
||||
});
|
||||
const controller = new DesktopUpdateController(packaged, async () => fake.updater, clock);
|
||||
|
||||
await controller.check(false);
|
||||
await vi.waitFor(() => expect(fake.updater.downloadUpdate).toHaveBeenCalledTimes(1));
|
||||
await vi.waitFor(() => expect(controller.state().phase).toBe("ready"));
|
||||
expect(controller.state()).toMatchObject({
|
||||
availableVersion: "0.2.0",
|
||||
percent: 100,
|
||||
});
|
||||
});
|
||||
|
||||
it("joins manual downloads to the automatic download already in flight", async () => {
|
||||
const download = deferred();
|
||||
let fake: ReturnType<typeof fakeUpdater>;
|
||||
fake = fakeUpdater({
|
||||
checkForUpdates: vi.fn(async () => {
|
||||
fake.emit("checking-for-update");
|
||||
fake.emit("update-available", { version: "0.2.0" });
|
||||
}),
|
||||
downloadUpdate: vi.fn(() => download.promise),
|
||||
});
|
||||
const controller = new DesktopUpdateController(packaged, async () => fake.updater, clock);
|
||||
|
||||
await controller.check(false);
|
||||
const first = controller.download();
|
||||
const second = controller.download();
|
||||
expect(second).toBe(first);
|
||||
expect(fake.updater.downloadUpdate).toHaveBeenCalledTimes(1);
|
||||
download.resolve();
|
||||
await first;
|
||||
});
|
||||
|
||||
it("does not treat later background failures as user-requested", async () => {
|
||||
let now = 1_000;
|
||||
let fake: ReturnType<typeof fakeUpdater>;
|
||||
fake = fakeUpdater({
|
||||
checkForUpdates: vi.fn(async () => {
|
||||
fake.emit("checking-for-update");
|
||||
if (vi.mocked(fake.updater.checkForUpdates).mock.calls.length === 1) {
|
||||
fake.emit("update-not-available");
|
||||
} else {
|
||||
fake.emit("error", new Error("getaddrinfo ENOTFOUND github.com"));
|
||||
}
|
||||
}),
|
||||
});
|
||||
const controller = new DesktopUpdateController(packaged, async () => fake.updater, {
|
||||
now: () => now,
|
||||
iso: () => NOW,
|
||||
});
|
||||
|
||||
await controller.check(true);
|
||||
now += MIN_CHECK_INTERVAL_MS;
|
||||
await controller.check(false);
|
||||
|
||||
expect(controller.state()).toMatchObject({ phase: "idle", message: null });
|
||||
});
|
||||
|
||||
it("runs installation at most once", async () => {
|
||||
let fake: ReturnType<typeof fakeUpdater>;
|
||||
fake = fakeUpdater({
|
||||
checkForUpdates: vi.fn(async () => {
|
||||
fake.emit("checking-for-update");
|
||||
fake.emit("update-available", { version: "0.2.0" });
|
||||
}),
|
||||
downloadUpdate: vi.fn(async () => {
|
||||
fake.emit("update-downloaded", { version: "0.2.0" });
|
||||
}),
|
||||
});
|
||||
const controller = new DesktopUpdateController(packaged, async () => fake.updater, clock);
|
||||
await controller.check(false);
|
||||
await vi.waitFor(() => expect(controller.state().phase).toBe("ready"));
|
||||
|
||||
await Promise.all([controller.install(), controller.install()]);
|
||||
expect(fake.updater.quitAndInstall).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("reports install failures instead of staying ready", async () => {
|
||||
let fake: ReturnType<typeof fakeUpdater>;
|
||||
fake = fakeUpdater({
|
||||
checkForUpdates: vi.fn(async () => {
|
||||
fake.emit("checking-for-update");
|
||||
fake.emit("update-available", { version: "0.2.0" });
|
||||
}),
|
||||
downloadUpdate: vi.fn(async () => {
|
||||
fake.emit("update-downloaded", { version: "0.2.0" });
|
||||
}),
|
||||
quitAndInstall: vi.fn(() => {
|
||||
throw new Error("quitAndInstall failed");
|
||||
}),
|
||||
});
|
||||
const controller = new DesktopUpdateController(packaged, async () => fake.updater, clock);
|
||||
await controller.check(false);
|
||||
await vi.waitFor(() => expect(controller.state().phase).toBe("ready"));
|
||||
|
||||
const state = await controller.install();
|
||||
expect(state).toMatchObject({
|
||||
phase: "error",
|
||||
message: "The update could not be completed. Try again later.",
|
||||
});
|
||||
expect(await controller.install()).toMatchObject({ phase: "error" });
|
||||
});
|
||||
|
||||
it("lets a manual check escape a prior empty-feed freeze", async () => {
|
||||
let fake: ReturnType<typeof fakeUpdater>;
|
||||
fake = fakeUpdater({
|
||||
checkForUpdates: vi.fn(async () => {
|
||||
fake.emit("checking-for-update");
|
||||
if (vi.mocked(fake.updater.checkForUpdates).mock.calls.length === 1) {
|
||||
fake.emit("error", new Error("HttpError: 404 Not Found"));
|
||||
} else {
|
||||
fake.emit("update-not-available");
|
||||
}
|
||||
}),
|
||||
});
|
||||
const controller = new DesktopUpdateController(packaged, async () => fake.updater, clock);
|
||||
|
||||
await controller.check(false);
|
||||
expect(controller.state().phase).toBe("unsupported");
|
||||
|
||||
await controller.check(true);
|
||||
expect(fake.updater.checkForUpdates).toHaveBeenCalledTimes(2);
|
||||
expect(controller.state()).toMatchObject({ phase: "idle", message: null });
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,409 @@
|
|||
import type { DesktopUpdateState } from "@rakazo/contracts";
|
||||
|
||||
/** Long enough that a cold launch is never competing with the update feed for bandwidth. */
|
||||
export const LAUNCH_CHECK_DELAY_MS = 8_000;
|
||||
/** A connected server drives the renderer, so a manual check cannot become a request loop. */
|
||||
export const MIN_CHECK_INTERVAL_MS = 60_000;
|
||||
|
||||
export interface UpdaterEnvironment {
|
||||
packaged: boolean;
|
||||
version: string;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export function updaterSupport(env: UpdaterEnvironment): { supported: boolean; reason: string } {
|
||||
if (env.disabled === true) {
|
||||
return { supported: false, reason: "Automatic updates are turned off for this install." };
|
||||
}
|
||||
if (!env.packaged) {
|
||||
return { supported: false, reason: "Automatic updates only run in an installed build." };
|
||||
}
|
||||
return { supported: true, reason: "" };
|
||||
}
|
||||
|
||||
export function initialUpdateState(env: UpdaterEnvironment): DesktopUpdateState {
|
||||
const support = updaterSupport(env);
|
||||
return {
|
||||
phase: support.supported ? "idle" : "unsupported",
|
||||
currentVersion: env.version,
|
||||
availableVersion: null,
|
||||
percent: null,
|
||||
message: support.supported ? null : support.reason,
|
||||
checkedAt: null,
|
||||
};
|
||||
}
|
||||
|
||||
export type UpdaterFailure =
|
||||
| { kind: "no-releases"; message: string }
|
||||
| { kind: "offline"; message: null }
|
||||
| { kind: "signature"; message: string }
|
||||
| { kind: "other"; message: string };
|
||||
|
||||
const NO_RELEASES = [
|
||||
"404 not found",
|
||||
"no published versions",
|
||||
"cannot find latest.yml",
|
||||
"cannot find latest-mac.yml",
|
||||
"unable to find latest version",
|
||||
];
|
||||
const OFFLINE = [
|
||||
"enotfound",
|
||||
"econnrefused",
|
||||
"econnreset",
|
||||
"etimedout",
|
||||
"eai_again",
|
||||
"enetunreach",
|
||||
"net::err_",
|
||||
"getaddrinfo",
|
||||
];
|
||||
const SIGNATURE = ["code sign", "signature", "not signed", "notariz"];
|
||||
|
||||
/** Error text can contain local paths or URLs, so renderer-visible messages stay generic. */
|
||||
export function classifyUpdaterFailure(error: unknown): UpdaterFailure {
|
||||
const text = (error instanceof Error ? error.message : String(error)).trim().toLowerCase();
|
||||
if (NO_RELEASES.some((needle) => text.includes(needle))) {
|
||||
return {
|
||||
kind: "no-releases",
|
||||
message: "No desktop releases are published for this build yet.",
|
||||
};
|
||||
}
|
||||
if (OFFLINE.some((needle) => text.includes(needle))) {
|
||||
return { kind: "offline", message: null };
|
||||
}
|
||||
if (SIGNATURE.some((needle) => text.includes(needle))) {
|
||||
return {
|
||||
kind: "signature",
|
||||
message: "This update could not be verified. Reinstall BangSo Bot from a trusted download.",
|
||||
};
|
||||
}
|
||||
return {
|
||||
kind: "other",
|
||||
message: "The update could not be completed. Try again later.",
|
||||
};
|
||||
}
|
||||
|
||||
export type UpdaterEvent =
|
||||
| { type: "check-start" }
|
||||
| { type: "available"; version: string }
|
||||
| { type: "not-available" }
|
||||
| { type: "download-start" }
|
||||
| { type: "progress"; percent: number }
|
||||
| { type: "downloaded"; version: string }
|
||||
| { type: "failed"; error: unknown; userInitiated: boolean; installFailed?: boolean };
|
||||
|
||||
export function reduceUpdateState(
|
||||
state: DesktopUpdateState,
|
||||
event: UpdaterEvent,
|
||||
now: string,
|
||||
): DesktopUpdateState {
|
||||
if (state.phase === "unsupported") return state;
|
||||
switch (event.type) {
|
||||
case "check-start":
|
||||
// A verified download in flight must not be reset by a stray updater event.
|
||||
if (state.phase === "available" || state.phase === "downloading" || state.phase === "ready") {
|
||||
return state;
|
||||
}
|
||||
return { ...state, phase: "checking", percent: null, message: null };
|
||||
case "available":
|
||||
if (state.phase === "downloading" || state.phase === "ready") return state;
|
||||
return {
|
||||
...state,
|
||||
phase: "available",
|
||||
availableVersion: event.version,
|
||||
percent: null,
|
||||
message: null,
|
||||
checkedAt: now,
|
||||
};
|
||||
case "not-available":
|
||||
if (state.phase === "downloading" || state.phase === "ready") return state;
|
||||
return {
|
||||
...state,
|
||||
phase: "idle",
|
||||
availableVersion: null,
|
||||
percent: null,
|
||||
message: null,
|
||||
checkedAt: now,
|
||||
};
|
||||
case "download-start":
|
||||
if (state.phase === "ready") return state;
|
||||
return { ...state, phase: "downloading", percent: 0, message: null };
|
||||
case "progress":
|
||||
// Only advance while a download is expected; late progress after error/idle
|
||||
// must not force the UI back into downloading.
|
||||
if (state.phase !== "available" && state.phase !== "downloading") return state;
|
||||
return {
|
||||
...state,
|
||||
phase: "downloading",
|
||||
percent: Math.max(0, Math.min(100, Math.round(event.percent))),
|
||||
};
|
||||
case "downloaded":
|
||||
return {
|
||||
...state,
|
||||
phase: "ready",
|
||||
availableVersion: event.version,
|
||||
percent: 100,
|
||||
message: "Restart BangSo Bot to finish the update.",
|
||||
};
|
||||
case "failed": {
|
||||
// electron-updater can emit late errors after a verified download; keep installable
|
||||
// state unless this failure came from quitAndInstall itself.
|
||||
if (state.phase === "ready" && event.installFailed !== true) return state;
|
||||
const failure = classifyUpdaterFailure(event.error);
|
||||
if (failure.kind === "no-releases" && state.phase === "checking") {
|
||||
return {
|
||||
...state,
|
||||
phase: "unsupported",
|
||||
availableVersion: null,
|
||||
percent: null,
|
||||
message: failure.message,
|
||||
checkedAt: now,
|
||||
};
|
||||
}
|
||||
if (failure.kind === "no-releases") {
|
||||
return {
|
||||
...state,
|
||||
phase: "error",
|
||||
percent: null,
|
||||
message: "The update could not be completed. Try again later.",
|
||||
checkedAt: now,
|
||||
};
|
||||
}
|
||||
if (failure.kind === "offline" && event.installFailed !== true) {
|
||||
return {
|
||||
...state,
|
||||
phase: "idle",
|
||||
percent: null,
|
||||
message: event.userInitiated ? "Could not reach the update server." : null,
|
||||
checkedAt: now,
|
||||
};
|
||||
}
|
||||
return { ...state, phase: "error", percent: null, message: failure.message, checkedAt: now };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export interface ShouldCheckOptions {
|
||||
/** Manual checks may retry after a prior empty feed; unpackaged installs stay frozen. */
|
||||
userInitiated?: boolean;
|
||||
environmentSupportsUpdates?: boolean;
|
||||
}
|
||||
|
||||
/** Checks never replace an offer that is already downloading or ready to install. */
|
||||
export function shouldCheck(
|
||||
state: DesktopUpdateState,
|
||||
now: number,
|
||||
lastCheck: number,
|
||||
options: ShouldCheckOptions = {},
|
||||
): boolean {
|
||||
if (state.phase === "unsupported") {
|
||||
return options.userInitiated === true && options.environmentSupportsUpdates === true;
|
||||
}
|
||||
if (state.phase !== "idle" && state.phase !== "error") return false;
|
||||
return now - lastCheck >= MIN_CHECK_INTERVAL_MS || lastCheck === 0;
|
||||
}
|
||||
|
||||
export interface ElectronAutoUpdater {
|
||||
autoDownload: boolean;
|
||||
autoInstallOnAppQuit: boolean;
|
||||
allowDowngrade: boolean;
|
||||
allowPrerelease: boolean;
|
||||
disableWebInstaller: boolean;
|
||||
on: (event: string, listener: (payload: unknown) => void) => unknown;
|
||||
checkForUpdates: () => Promise<unknown>;
|
||||
downloadUpdate: () => Promise<unknown>;
|
||||
quitAndInstall: () => void;
|
||||
}
|
||||
|
||||
interface UpdateClock {
|
||||
now: () => number;
|
||||
iso: () => string;
|
||||
}
|
||||
|
||||
const systemClock: UpdateClock = {
|
||||
now: Date.now,
|
||||
iso: () => new Date().toISOString(),
|
||||
};
|
||||
|
||||
function versionFrom(payload: unknown): string | null {
|
||||
if (typeof payload !== "object" || payload === null || !("version" in payload)) return null;
|
||||
return typeof payload.version === "string" && payload.version.trim() !== ""
|
||||
? payload.version
|
||||
: null;
|
||||
}
|
||||
|
||||
function percentFrom(payload: unknown): number | null {
|
||||
if (typeof payload !== "object" || payload === null || !("percent" in payload)) return null;
|
||||
return typeof payload.percent === "number" && Number.isFinite(payload.percent)
|
||||
? payload.percent
|
||||
: null;
|
||||
}
|
||||
|
||||
/** Owns updater configuration and serializes renderer and launch-time operations. */
|
||||
export class DesktopUpdateController {
|
||||
private current: DesktopUpdateState;
|
||||
private lastCheck = 0;
|
||||
private updaterPromise: Promise<ElectronAutoUpdater | null> | null = null;
|
||||
private checkPromise: Promise<DesktopUpdateState> | null = null;
|
||||
private downloadPromise: Promise<DesktopUpdateState> | null = null;
|
||||
private checkWasRequested = false;
|
||||
private installStarted = false;
|
||||
|
||||
constructor(
|
||||
private readonly environment: UpdaterEnvironment,
|
||||
private readonly loadUpdater: () => Promise<ElectronAutoUpdater>,
|
||||
private readonly clock: UpdateClock = systemClock,
|
||||
) {
|
||||
this.current = initialUpdateState(environment);
|
||||
}
|
||||
|
||||
state() {
|
||||
return this.current;
|
||||
}
|
||||
|
||||
private push(event: UpdaterEvent) {
|
||||
this.current = reduceUpdateState(this.current, event, this.clock.iso());
|
||||
if (event.type === "not-available" || event.type === "downloaded" || event.type === "failed") {
|
||||
this.checkWasRequested = false;
|
||||
}
|
||||
}
|
||||
|
||||
private async updater(): Promise<ElectronAutoUpdater | null> {
|
||||
if (!updaterSupport(this.environment).supported) return null;
|
||||
if (this.updaterPromise !== null) return this.updaterPromise;
|
||||
|
||||
const loading = this.loadUpdater()
|
||||
.then((updater) => {
|
||||
updater.autoDownload = false;
|
||||
updater.autoInstallOnAppQuit = true;
|
||||
updater.allowPrerelease = false;
|
||||
updater.allowDowngrade = false;
|
||||
updater.disableWebInstaller = true;
|
||||
updater.on("checking-for-update", () => this.push({ type: "check-start" }));
|
||||
updater.on("update-available", (payload) => {
|
||||
const version = versionFrom(payload);
|
||||
if (version === null) {
|
||||
this.push({
|
||||
type: "failed",
|
||||
error: new Error("The update feed did not include a version."),
|
||||
userInitiated: this.checkWasRequested,
|
||||
});
|
||||
return;
|
||||
}
|
||||
this.push({ type: "available", version });
|
||||
void this.download();
|
||||
});
|
||||
updater.on("update-not-available", () => this.push({ type: "not-available" }));
|
||||
updater.on("download-progress", (payload) => {
|
||||
const percent = percentFrom(payload);
|
||||
if (percent !== null) this.push({ type: "progress", percent });
|
||||
});
|
||||
updater.on("update-downloaded", (payload) => {
|
||||
const version = versionFrom(payload);
|
||||
if (version !== null) {
|
||||
this.push({ type: "downloaded", version });
|
||||
} else {
|
||||
this.push({
|
||||
type: "failed",
|
||||
error: new Error("The downloaded update did not include a version."),
|
||||
userInitiated: this.checkWasRequested,
|
||||
});
|
||||
}
|
||||
});
|
||||
updater.on("error", (error) =>
|
||||
this.push({ type: "failed", error, userInitiated: this.checkWasRequested }),
|
||||
);
|
||||
return updater;
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
this.push({ type: "failed", error, userInitiated: this.checkWasRequested });
|
||||
if (this.updaterPromise === loading) this.updaterPromise = null;
|
||||
return null;
|
||||
});
|
||||
this.updaterPromise = loading;
|
||||
return loading;
|
||||
}
|
||||
|
||||
check(userInitiated: boolean) {
|
||||
if (userInitiated) this.checkWasRequested = true;
|
||||
if (this.checkPromise !== null) return this.checkPromise;
|
||||
const checking = this.runCheck().finally(() => {
|
||||
if (this.checkPromise === checking) this.checkPromise = null;
|
||||
});
|
||||
this.checkPromise = checking;
|
||||
return checking;
|
||||
}
|
||||
|
||||
private async runCheck() {
|
||||
const checkOptions = {
|
||||
userInitiated: this.checkWasRequested,
|
||||
environmentSupportsUpdates: updaterSupport(this.environment).supported,
|
||||
};
|
||||
let now = this.clock.now();
|
||||
if (!shouldCheck(this.current, now, this.lastCheck, checkOptions)) return this.current;
|
||||
// A prior empty feed freezes automatic checks; a manual retry clears that freeze.
|
||||
if (this.current.phase === "unsupported" && checkOptions.environmentSupportsUpdates) {
|
||||
this.current = {
|
||||
...this.current,
|
||||
phase: "idle",
|
||||
availableVersion: null,
|
||||
percent: null,
|
||||
message: null,
|
||||
};
|
||||
// Do not make the user wait out the launch-check interval to retry.
|
||||
this.lastCheck = 0;
|
||||
}
|
||||
const updater = await this.updater();
|
||||
if (updater === null) return this.current;
|
||||
now = this.clock.now();
|
||||
if (!shouldCheck(this.current, now, this.lastCheck, checkOptions)) return this.current;
|
||||
this.lastCheck = now;
|
||||
try {
|
||||
await updater.checkForUpdates();
|
||||
} catch (error) {
|
||||
if (this.current.phase === "idle" || this.current.phase === "checking") {
|
||||
this.push({ type: "failed", error, userInitiated: this.checkWasRequested });
|
||||
}
|
||||
}
|
||||
return this.current;
|
||||
}
|
||||
|
||||
download() {
|
||||
if (this.downloadPromise !== null) return this.downloadPromise;
|
||||
const downloading = this.runDownload().finally(() => {
|
||||
if (this.downloadPromise === downloading) this.downloadPromise = null;
|
||||
});
|
||||
this.downloadPromise = downloading;
|
||||
return downloading;
|
||||
}
|
||||
|
||||
private async runDownload() {
|
||||
if (this.current.phase !== "available") return this.current;
|
||||
const updater = await this.updater();
|
||||
if (updater === null || this.current.phase !== "available") return this.current;
|
||||
this.push({ type: "download-start" });
|
||||
try {
|
||||
await updater.downloadUpdate();
|
||||
} catch (error) {
|
||||
if (this.state().phase === "downloading") {
|
||||
this.push({ type: "failed", error, userInitiated: this.checkWasRequested });
|
||||
}
|
||||
}
|
||||
return this.current;
|
||||
}
|
||||
|
||||
async install() {
|
||||
if (this.installStarted || this.current.phase !== "ready") return this.current;
|
||||
const updater = await this.updater();
|
||||
if (updater === null || this.installStarted || this.current.phase !== "ready") {
|
||||
return this.current;
|
||||
}
|
||||
this.installStarted = true;
|
||||
try {
|
||||
updater.quitAndInstall();
|
||||
} catch (error) {
|
||||
this.installStarted = false;
|
||||
this.push({ type: "failed", error, userInitiated: true, installFailed: true });
|
||||
}
|
||||
return this.current;
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
|
|
@ -0,0 +1,59 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import { oauthCallbackFrom } from "./oauth-callback.js";
|
||||
|
||||
describe("loopback OAuth callbacks", () => {
|
||||
it("reads the code and state Anthropic redirects with", () => {
|
||||
expect(
|
||||
oauthCallbackFrom("http://localhost:53692/callback?code=ac_123&state=verifier_456"),
|
||||
).toEqual({ code: "ac_123", state: "verifier_456" });
|
||||
});
|
||||
|
||||
it("accepts the loopback addresses a provider may redirect to", () => {
|
||||
expect(oauthCallbackFrom("http://127.0.0.1:53692/callback?code=ac_123")).toEqual({
|
||||
code: "ac_123",
|
||||
});
|
||||
expect(oauthCallbackFrom("http://[::1]:53692/callback?code=ac_123")).toEqual({
|
||||
code: "ac_123",
|
||||
});
|
||||
});
|
||||
|
||||
it("omits state when the provider redirects without one", () => {
|
||||
expect(oauthCallbackFrom("http://localhost:53692/callback?code=ac_123")).toEqual({
|
||||
code: "ac_123",
|
||||
});
|
||||
});
|
||||
|
||||
it("ignores the authorize page and other steps of the flow", () => {
|
||||
expect(oauthCallbackFrom("https://claude.ai/oauth/authorize?code=true")).toBeUndefined();
|
||||
expect(oauthCallbackFrom("http://localhost:53692/callback")).toBeUndefined();
|
||||
expect(oauthCallbackFrom("http://localhost:5173/")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("does not treat a remote host as a loopback callback", () => {
|
||||
expect(oauthCallbackFrom("https://example.com/callback?code=ac_123")).toBeUndefined();
|
||||
expect(oauthCallbackFrom("https://localhost.example.com/callback?code=ac_123")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("ignores non-http schemes and unparseable targets", () => {
|
||||
expect(oauthCallbackFrom("file:///callback?code=ac_123")).toBeUndefined();
|
||||
expect(oauthCallbackFrom("rakazo://localhost/callback?code=ac_123")).toBeUndefined();
|
||||
expect(oauthCallbackFrom("not a url")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("ignores whitespace-only codes", () => {
|
||||
expect(oauthCallbackFrom("http://localhost:53692/callback?code=%20%20")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("does not capture the app renderer origin used by MCP and other in-app callbacks", () => {
|
||||
expect(
|
||||
oauthCallbackFrom("http://127.0.0.1:5173/mcp/oauth/callback?code=mcp_123&state=s", {
|
||||
excludeOrigins: ["http://127.0.0.1:5173"],
|
||||
}),
|
||||
).toBeUndefined();
|
||||
expect(
|
||||
oauthCallbackFrom("http://localhost:53692/callback?code=ac_123", {
|
||||
excludeOrigins: ["http://127.0.0.1:5173"],
|
||||
}),
|
||||
).toEqual({ code: "ac_123" });
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,35 @@
|
|||
import type { RakazoDesktopOAuthCallback } from "@rakazo/contracts";
|
||||
|
||||
export type OAuthCallbackFromOptions = {
|
||||
/** App renderer origins — their `/callback` routes must not be treated as paste-flow codes. */
|
||||
excludeOrigins?: readonly string[];
|
||||
};
|
||||
|
||||
const LOOPBACK_HOSTS = new Set(["localhost", "127.0.0.1", "[::1]"]);
|
||||
|
||||
/**
|
||||
* Providers that sign in through a loopback redirect — Anthropic sends the
|
||||
* browser to `http://localhost:53692/callback` — return the authorization code
|
||||
* in the redirect URL. Rakazo runs no listener on that port and asks for the
|
||||
* code to be pasted instead, which the sign-in popup cannot show because an
|
||||
* Electron window has no address bar. The main process still sees the
|
||||
* navigation, so it reads the code from there.
|
||||
*/
|
||||
export function oauthCallbackFrom(
|
||||
url: string,
|
||||
options: OAuthCallbackFromOptions = {},
|
||||
): RakazoDesktopOAuthCallback | undefined {
|
||||
let target: URL;
|
||||
try {
|
||||
target = new URL(url);
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
if (target.protocol !== "http:" && target.protocol !== "https:") return undefined;
|
||||
if (!LOOPBACK_HOSTS.has(target.hostname)) return undefined;
|
||||
if (options.excludeOrigins?.includes(target.origin)) return undefined;
|
||||
const code = target.searchParams.get("code")?.trim();
|
||||
if (!code) return undefined;
|
||||
const state = target.searchParams.get("state")?.trim();
|
||||
return state ? { code, state } : { code };
|
||||
}
|
||||
|
|
@ -0,0 +1,25 @@
|
|||
const { contextBridge, ipcRenderer } = require("electron");
|
||||
|
||||
contextBridge.exposeInMainWorld("rakazoDesktop", {
|
||||
platform: process.platform,
|
||||
window: {
|
||||
close: () => ipcRenderer.invoke("desktop.window.close"),
|
||||
minimize: () => ipcRenderer.invoke("desktop.window.minimize"),
|
||||
toggleMaximize: () => ipcRenderer.invoke("desktop.window.toggleMaximize"),
|
||||
state: () => ipcRenderer.invoke("desktop.window.state"),
|
||||
},
|
||||
update: {
|
||||
state: () => ipcRenderer.invoke("desktop.update.state"),
|
||||
check: () => ipcRenderer.invoke("desktop.update.check"),
|
||||
download: () => ipcRenderer.invoke("desktop.update.download"),
|
||||
install: () => ipcRenderer.invoke("desktop.update.install"),
|
||||
},
|
||||
oauth: {
|
||||
onCallback: (listener) => {
|
||||
// The IpcRendererEvent stays in the preload: the renderer only sees the code.
|
||||
const handler = (_event, callback) => listener(callback);
|
||||
ipcRenderer.on("desktop.oauth.callback", handler);
|
||||
return () => ipcRenderer.off("desktop.oauth.callback", handler);
|
||||
},
|
||||
},
|
||||
});
|
||||
|
|
@ -0,0 +1,110 @@
|
|||
import { readFileSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
import vm from "node:vm";
|
||||
import type { RakazoDesktop, RakazoSetup } from "@rakazo/contracts";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
function runPreload(file: string, ipc: { invoke?: unknown; on?: unknown; off?: unknown } = {}) {
|
||||
const invoke =
|
||||
(ipc.invoke as ReturnType<typeof vi.fn>) ?? vi.fn(async (channel: string) => ({ channel }));
|
||||
const on = (ipc.on as ReturnType<typeof vi.fn>) ?? vi.fn();
|
||||
const off = (ipc.off as ReturnType<typeof vi.fn>) ?? vi.fn();
|
||||
const exposeInMainWorld = vi.fn();
|
||||
const source = readFileSync(path.join(import.meta.dirname, file), "utf8");
|
||||
|
||||
vm.runInNewContext(source, {
|
||||
process: { platform: "linux" },
|
||||
require(moduleName: string) {
|
||||
if (moduleName !== "electron") throw new Error(`Unexpected preload import: ${moduleName}`);
|
||||
return { contextBridge: { exposeInMainWorld }, ipcRenderer: { invoke, on, off } };
|
||||
},
|
||||
});
|
||||
|
||||
return { invoke, on, off, exposeInMainWorld };
|
||||
}
|
||||
|
||||
describe("desktop preload bridge", () => {
|
||||
it("exposes only the platform, the four window operations, the updater, and the OAuth bridge", async () => {
|
||||
const { invoke, exposeInMainWorld } = runPreload("preload.cjs");
|
||||
|
||||
expect(exposeInMainWorld).toHaveBeenCalledTimes(1);
|
||||
const [globalName, bridge] = exposeInMainWorld.mock.calls[0] as [string, RakazoDesktop];
|
||||
expect(globalName).toBe("rakazoDesktop");
|
||||
expect(bridge.platform).toBe("linux");
|
||||
expect(Object.keys(bridge).sort()).toEqual(["oauth", "platform", "update", "window"]);
|
||||
expect(Object.keys(bridge.window).sort()).toEqual([
|
||||
"close",
|
||||
"minimize",
|
||||
"state",
|
||||
"toggleMaximize",
|
||||
]);
|
||||
expect(Object.keys(bridge.update).sort()).toEqual(["check", "download", "install", "state"]);
|
||||
|
||||
await bridge.window.close();
|
||||
await bridge.window.minimize();
|
||||
await bridge.window.toggleMaximize();
|
||||
await bridge.window.state();
|
||||
await bridge.update.state();
|
||||
await bridge.update.check();
|
||||
await bridge.update.download();
|
||||
await bridge.update.install();
|
||||
expect(invoke.mock.calls.map(([channel]) => channel)).toEqual([
|
||||
"desktop.window.close",
|
||||
"desktop.window.minimize",
|
||||
"desktop.window.toggleMaximize",
|
||||
"desktop.window.state",
|
||||
"desktop.update.state",
|
||||
"desktop.update.check",
|
||||
"desktop.update.download",
|
||||
"desktop.update.install",
|
||||
]);
|
||||
});
|
||||
|
||||
it("keeps setup off the app bridge so a connected server cannot re-point the app", () => {
|
||||
const { exposeInMainWorld } = runPreload("preload.cjs");
|
||||
const [, bridge] = exposeInMainWorld.mock.calls[0] as [string, Record<string, unknown>];
|
||||
expect(Object.keys(bridge).sort()).toEqual(["oauth", "platform", "update", "window"]);
|
||||
});
|
||||
|
||||
it("forwards captured codes without leaking the IPC event to the renderer", () => {
|
||||
const listeners: Array<(event: unknown, callback: unknown) => void> = [];
|
||||
const on = vi.fn((_channel: string, handler: (event: unknown, callback: unknown) => void) => {
|
||||
listeners.push(handler);
|
||||
});
|
||||
const off = vi.fn();
|
||||
const { exposeInMainWorld } = runPreload("preload.cjs", { on, off });
|
||||
|
||||
const [, bridge] = exposeInMainWorld.mock.calls[0] as [string, RakazoDesktop];
|
||||
const received: unknown[] = [];
|
||||
const unsubscribe = bridge.oauth.onCallback((callback) => received.push(callback));
|
||||
|
||||
expect(on).toHaveBeenCalledWith("desktop.oauth.callback", expect.any(Function));
|
||||
listeners[0]?.({ sender: "ipc-event" }, { code: "ac_123", state: "verifier_456" });
|
||||
expect(received).toEqual([{ code: "ac_123", state: "verifier_456" }]);
|
||||
|
||||
unsubscribe();
|
||||
expect(off).toHaveBeenCalledWith("desktop.oauth.callback", expect.any(Function));
|
||||
});
|
||||
});
|
||||
|
||||
describe("setup preload bridge", () => {
|
||||
it("exposes only the first-run setup operations", async () => {
|
||||
const { invoke, exposeInMainWorld } = runPreload("setup-preload.cjs");
|
||||
|
||||
expect(exposeInMainWorld).toHaveBeenCalledTimes(1);
|
||||
const [globalName, bridge] = exposeInMainWorld.mock.calls[0] as [string, RakazoSetup];
|
||||
expect(globalName).toBe("rakazoSetup");
|
||||
expect(Object.keys(bridge).sort()).toEqual(["quit", "save", "state", "test"]);
|
||||
|
||||
await bridge.state();
|
||||
await bridge.test("http://127.0.0.1:5173");
|
||||
await bridge.save({ mode: "new", serverUrl: "http://127.0.0.1:5173" });
|
||||
await bridge.quit();
|
||||
expect(invoke.mock.calls.map(([channel]) => channel)).toEqual([
|
||||
"desktop.setup.state",
|
||||
"desktop.setup.test",
|
||||
"desktop.setup.save",
|
||||
"desktop.setup.quit",
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,61 @@
|
|||
import { readFileSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
const workflow = readFileSync(
|
||||
path.resolve(import.meta.dirname, "../../../.github/workflows/release-desktop.yml"),
|
||||
"utf8",
|
||||
);
|
||||
|
||||
describe("desktop release workflow", () => {
|
||||
it("cannot execute contributor pull-request code with release credentials", () => {
|
||||
expect(workflow).not.toMatch(/^\s*pull_request:/m);
|
||||
expect(workflow).toContain("permissions:\n contents: read");
|
||||
expect(workflow.match(/persist-credentials: false/g)).toHaveLength(3);
|
||||
});
|
||||
|
||||
it("pins every third-party action to an immutable commit", () => {
|
||||
const actionReferences = [...workflow.matchAll(/uses:\s+([^\s#]+)/g)].map((match) => match[1]);
|
||||
expect(actionReferences.length).toBeGreaterThan(0);
|
||||
for (const reference of actionReferences) {
|
||||
expect(reference, reference).toMatch(/@[0-9a-f]{40}$/);
|
||||
}
|
||||
});
|
||||
|
||||
it("requires signed platform builds before a single publication job", () => {
|
||||
expect(workflow).toContain("-c.forceCodeSigning=true");
|
||||
expect(workflow).toContain("codesign --verify --deep --strict");
|
||||
expect(workflow).toContain("Get-AuthenticodeSignature");
|
||||
expect(workflow).toContain("needs: [validate, build]");
|
||||
expect(workflow).toContain("attestations: write");
|
||||
expect(workflow).toContain("actions/attest-build-provenance@");
|
||||
expect(workflow).not.toContain("--publish always");
|
||||
expect(workflow).toContain("DESKTOP_MAC_CSC_LINK");
|
||||
expect(workflow).toContain("DESKTOP_WIN_CSC_LINK");
|
||||
expect(workflow).not.toMatch(/secrets\.DESKTOP_CSC_(?:LINK|KEY_PASSWORD)/);
|
||||
expect(workflow).not.toContain("cache: pnpm");
|
||||
expect(workflow).toContain("apps/desktop/out/latest*.yml");
|
||||
expect(workflow).not.toContain("apps/desktop/out/*.yml");
|
||||
});
|
||||
|
||||
it("pins every platform update feed to the official GitHub owner and repo", () => {
|
||||
expect(workflow).toContain('grep -Fqx "provider: github"');
|
||||
expect(workflow).toContain('grep -Fqx "owner: elie222"');
|
||||
expect(workflow).toContain('grep -Fqx "repo: rakazo"');
|
||||
expect(workflow).toContain("Verify Linux update feed is pinned to the official GitHub channel");
|
||||
expect(workflow).toContain("Windows update config missing");
|
||||
expect(workflow).toContain("RELEASE_VERSION:");
|
||||
expect(workflow).toContain('grep -Fqx "version: $RELEASE_VERSION"');
|
||||
});
|
||||
|
||||
it("publishes only a complete stable, upgrade-only feed", () => {
|
||||
expect(workflow).toContain("^v([0-9]+)\\.([0-9]+)\\.([0-9]+)$");
|
||||
expect(workflow).toContain("must be newer than published release");
|
||||
expect(workflow).toContain("group: release-desktop-stable");
|
||||
expect(workflow).toContain("latest.yml");
|
||||
expect(workflow).toContain("latest-mac.yml");
|
||||
expect(workflow).toContain("latest-linux.yml");
|
||||
expect(workflow).toContain("--draft --generate-notes");
|
||||
expect(workflow).toContain("--draft=false --latest");
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,75 @@
|
|||
import path from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
bundledRendererCandidates,
|
||||
contentType,
|
||||
forwardedRendererRequestInit,
|
||||
immutableRendererAsset,
|
||||
isRendererAssetMiss,
|
||||
} from "./renderer-assets.js";
|
||||
|
||||
const root = path.resolve("/tmp/rakazo-renderer");
|
||||
const origin = "https://app.example.com";
|
||||
|
||||
describe("bundled desktop renderer", () => {
|
||||
it("serves static assets and falls back to index.html for app routes", () => {
|
||||
expect(bundledRendererCandidates(root, `${origin}/assets/app-123.js`, origin, false)).toEqual([
|
||||
path.join(root, "assets/app-123.js"),
|
||||
]);
|
||||
expect(bundledRendererCandidates(root, `${origin}/app/bot-1`, origin, true)).toEqual([
|
||||
path.join(root, "app/bot-1"),
|
||||
path.join(root, "index.html"),
|
||||
]);
|
||||
expect(bundledRendererCandidates(root, `${origin}/app/bot.name`, origin, true)).toEqual([
|
||||
path.join(root, "app/bot.name"),
|
||||
path.join(root, "index.html"),
|
||||
]);
|
||||
});
|
||||
|
||||
it("leaves APIs, screen proxying, and other origins on the network", () => {
|
||||
for (const pathname of ["/api/auth/session", "/rpc/bots", "/novnc/socket"]) {
|
||||
expect(bundledRendererCandidates(root, `${origin}${pathname}`, origin, false)).toBeNull();
|
||||
}
|
||||
expect(
|
||||
bundledRendererCandidates(root, "https://example.net/assets/app-123.js", origin, false),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it("does not resolve traversal outside the renderer root", () => {
|
||||
expect(
|
||||
bundledRendererCandidates(root, `${origin}/%2e%2e%2fsecret.txt`, origin, false),
|
||||
).toBeNull();
|
||||
expect(bundledRendererCandidates(root, `${origin}/bad%00name.js`, origin, false)).toBeNull();
|
||||
});
|
||||
|
||||
it("assigns cache and content metadata", () => {
|
||||
expect(contentType("index.html")).toBe("text/html; charset=utf-8");
|
||||
expect(contentType("app.woff2")).toBe("font/woff2");
|
||||
expect(immutableRendererAsset(path.join(root, "assets/app-123.js"))).toBe(true);
|
||||
expect(immutableRendererAsset(path.join(root, "index.html"))).toBe(false);
|
||||
});
|
||||
|
||||
it("treats filesystem path misses as renderer fallbacks", () => {
|
||||
for (const code of ["ENOENT", "EISDIR", "ENOTDIR"]) {
|
||||
expect(isRendererAssetMiss(Object.assign(new Error(code), { code }))).toBe(true);
|
||||
}
|
||||
expect(isRendererAssetMiss(Object.assign(new Error("denied"), { code: "EACCES" }))).toBe(false);
|
||||
expect(isRendererAssetMiss(null)).toBe(false);
|
||||
});
|
||||
|
||||
it("forwards cookies for both app and cross-origin requests", () => {
|
||||
const appOptions = forwardedRendererRequestInit(new Request(`${origin}/rpc/bots`), origin);
|
||||
const oauthOptions = forwardedRendererRequestInit(
|
||||
new Request("https://oauth.example.com/authorize"),
|
||||
origin,
|
||||
);
|
||||
|
||||
expect(appOptions.credentials).toBe("include");
|
||||
expect(new Headers(appOptions.headers).get("origin")).toBe(origin);
|
||||
expect(oauthOptions).toMatchObject({
|
||||
bypassCustomProtocolHandlers: true,
|
||||
credentials: "include",
|
||||
});
|
||||
expect(oauthOptions.headers).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,81 @@
|
|||
import path from "node:path";
|
||||
|
||||
const PASSTHROUGH_PATHS = ["/api", "/rpc", "/novnc"];
|
||||
|
||||
const CONTENT_TYPES: Record<string, string> = {
|
||||
".css": "text/css; charset=utf-8",
|
||||
".html": "text/html; charset=utf-8",
|
||||
".ico": "image/x-icon",
|
||||
".js": "text/javascript; charset=utf-8",
|
||||
".json": "application/json; charset=utf-8",
|
||||
".png": "image/png",
|
||||
".svg": "image/svg+xml",
|
||||
".webmanifest": "application/manifest+json; charset=utf-8",
|
||||
".woff": "font/woff",
|
||||
".woff2": "font/woff2",
|
||||
};
|
||||
|
||||
export function bundledRendererCandidates(
|
||||
root: string,
|
||||
requestUrl: string,
|
||||
webOrigin: string,
|
||||
acceptsHtml: boolean,
|
||||
) {
|
||||
const url = new URL(requestUrl);
|
||||
if (
|
||||
url.origin !== webOrigin ||
|
||||
PASSTHROUGH_PATHS.some((prefix) => matchesPrefix(url.pathname, prefix))
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let pathname: string;
|
||||
try {
|
||||
pathname = decodeURIComponent(url.pathname);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
if (pathname.includes("\0")) return null;
|
||||
|
||||
const requested = pathname === "/" ? "index.html" : pathname.slice(1);
|
||||
const candidate = safeChild(root, requested);
|
||||
if (!candidate) return null;
|
||||
return acceptsHtml ? [candidate, path.join(root, "index.html")] : [candidate];
|
||||
}
|
||||
|
||||
export function contentType(file: string) {
|
||||
return CONTENT_TYPES[path.extname(file).toLowerCase()] ?? "application/octet-stream";
|
||||
}
|
||||
|
||||
export function immutableRendererAsset(file: string) {
|
||||
return path.basename(path.dirname(file)) === "assets";
|
||||
}
|
||||
|
||||
export function isRendererAssetMiss(error: unknown) {
|
||||
const code =
|
||||
typeof error === "object" && error !== null && "code" in error ? error.code : undefined;
|
||||
return code === "ENOENT" || code === "EISDIR" || code === "ENOTDIR";
|
||||
}
|
||||
|
||||
export function forwardedRendererRequestInit(request: Request, webOrigin: string) {
|
||||
const init: RequestInit & { bypassCustomProtocolHandlers: boolean } = {
|
||||
bypassCustomProtocolHandlers: true,
|
||||
credentials: "include",
|
||||
};
|
||||
if (new URL(request.url).origin !== webOrigin) return init;
|
||||
const headers = new Headers(request.headers);
|
||||
headers.set("origin", webOrigin);
|
||||
return { ...init, headers };
|
||||
}
|
||||
|
||||
function matchesPrefix(pathname: string, prefix: string) {
|
||||
return pathname === prefix || pathname.startsWith(`${prefix}/`);
|
||||
}
|
||||
|
||||
function safeChild(root: string, requested: string) {
|
||||
const resolvedRoot = path.resolve(root);
|
||||
const resolved = path.resolve(resolvedRoot, requested);
|
||||
const relative = path.relative(resolvedRoot, resolved);
|
||||
if (relative.startsWith("..") || path.isAbsolute(relative)) return null;
|
||||
return resolved;
|
||||
}
|
||||
|
|
@ -0,0 +1,198 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
DEFAULT_LOCAL_WEB_URL,
|
||||
isRakazoHealth,
|
||||
normalizeServerUrl,
|
||||
parseSetupInput,
|
||||
parseStoredSetup,
|
||||
probeFailureMessage,
|
||||
resolveStartupTarget,
|
||||
safeExternalUrl,
|
||||
serializeSetup,
|
||||
servesBundledRenderer,
|
||||
sessionPartitionForServerUrl,
|
||||
} from "./setup-config.js";
|
||||
|
||||
describe("server address normalization", () => {
|
||||
it("assumes http locally and https for a bare public host", () => {
|
||||
expect(normalizeServerUrl("127.0.0.1:5173")).toBe("http://127.0.0.1:5173");
|
||||
expect(normalizeServerUrl("localhost:5173")).toBe("http://localhost:5173");
|
||||
expect(normalizeServerUrl("192.168.1.20:3100")).toBe("http://192.168.1.20:3100");
|
||||
expect(normalizeServerUrl("rakazo.example.com")).toBe("https://rakazo.example.com");
|
||||
});
|
||||
|
||||
it("keeps an explicit secure scheme and port but stores only the origin", () => {
|
||||
expect(normalizeServerUrl("https://rakazo.example.com")).toBe("https://rakazo.example.com");
|
||||
expect(normalizeServerUrl("https://rakazo.example.com:8443/team")).toBe(
|
||||
"https://rakazo.example.com:8443",
|
||||
);
|
||||
});
|
||||
|
||||
it("trims surrounding space, trailing slashes, queries, and fragments", () => {
|
||||
expect(normalizeServerUrl(" http://127.0.0.1:5173/ ")).toBe("http://127.0.0.1:5173");
|
||||
expect(normalizeServerUrl("http://127.0.0.1:5173///")).toBe("http://127.0.0.1:5173");
|
||||
expect(normalizeServerUrl("http://127.0.0.1:5173/?next=/bots#top")).toBe(
|
||||
"http://127.0.0.1:5173",
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects cleartext public servers but permits private-network development", () => {
|
||||
expect(normalizeServerUrl("http://rakazo.example.com")).toBeNull();
|
||||
expect(normalizeServerUrl("http://10.0.0.8:3100")).toBe("http://10.0.0.8:3100");
|
||||
expect(normalizeServerUrl("http://[fd00::1]:3100")).toBe("http://[fd00::1]:3100");
|
||||
});
|
||||
|
||||
it("rejects cleartext link-local addresses used by cloud metadata endpoints", () => {
|
||||
expect(normalizeServerUrl("http://169.254.169.254")).toBeNull();
|
||||
expect(normalizeServerUrl("http://169.254.1.1:80")).toBeNull();
|
||||
expect(normalizeServerUrl("http://[fe80::1]:3100")).toBeNull();
|
||||
// HTTPS to link-local still normalizes; the health probe must match Rakazo.
|
||||
expect(normalizeServerUrl("https://169.254.169.254")).toBe("https://169.254.169.254");
|
||||
});
|
||||
|
||||
it.each(["", " ", "not a url", "ftp://example.com", "file:///etc/passwd", "http://"])(
|
||||
"rejects an address that cannot reach a Rakazo server (%s)",
|
||||
(value) => {
|
||||
expect(normalizeServerUrl(value)).toBeNull();
|
||||
},
|
||||
);
|
||||
|
||||
it("rejects embedded credentials rather than writing them to disk", () => {
|
||||
expect(normalizeServerUrl("https://user:secret@rakazo.example.com")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("saved setup", () => {
|
||||
it("round-trips through the on-disk format", () => {
|
||||
const setup = { mode: "existing", serverUrl: "https://rakazo.example.com" } as const;
|
||||
expect(parseStoredSetup(serializeSetup(setup))).toEqual(setup);
|
||||
});
|
||||
|
||||
it("normalizes the address it reads back", () => {
|
||||
expect(parseStoredSetup('{"mode":"new","serverUrl":"127.0.0.1:5173/"}')).toEqual({
|
||||
mode: "new",
|
||||
serverUrl: "http://127.0.0.1:5173",
|
||||
});
|
||||
});
|
||||
|
||||
it.each([
|
||||
["not json", "{oops"],
|
||||
["a non-object", '"nope"'],
|
||||
["an unknown mode", '{"mode":"other","serverUrl":"http://127.0.0.1:5173"}'],
|
||||
["a missing address", '{"mode":"new"}'],
|
||||
["an unusable address", '{"mode":"new","serverUrl":"ftp://example.com"}'],
|
||||
])("discards %s so setup runs again", (_label, raw) => {
|
||||
expect(parseStoredSetup(raw)).toBeNull();
|
||||
});
|
||||
|
||||
it("rejects an untrusted payload that is not a setup", () => {
|
||||
expect(parseSetupInput(null)).toBeNull();
|
||||
expect(parseSetupInput({ mode: "new", serverUrl: 5173 })).toBeNull();
|
||||
});
|
||||
|
||||
it("keeps the new-instance choice on this computer", () => {
|
||||
expect(parseSetupInput({ mode: "new", serverUrl: "http://192.168.1.20:3100" })).toBeNull();
|
||||
expect(parseSetupInput({ mode: "existing", serverUrl: "http://192.168.1.20:3100" })).toEqual({
|
||||
mode: "existing",
|
||||
serverUrl: "http://192.168.1.20:3100",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("startup target", () => {
|
||||
const saved = { mode: "existing", serverUrl: "https://rakazo.example.com" } as const;
|
||||
|
||||
it("runs setup on a first launch", () => {
|
||||
expect(resolveStartupTarget({})).toEqual({ kind: "setup" });
|
||||
});
|
||||
|
||||
it("opens the saved instance on later launches", () => {
|
||||
expect(resolveStartupTarget({ saved })).toEqual({
|
||||
kind: "app",
|
||||
url: "https://rakazo.example.com",
|
||||
source: "saved",
|
||||
});
|
||||
});
|
||||
|
||||
it("lets RAKAZO_WEB_URL point the shell anywhere without touching saved setup", () => {
|
||||
expect(resolveStartupTarget({ envUrl: "http://127.0.0.1:4321", saved })).toEqual({
|
||||
kind: "app",
|
||||
url: "http://127.0.0.1:4321",
|
||||
source: "env",
|
||||
});
|
||||
});
|
||||
|
||||
it("ignores an empty RAKAZO_WEB_URL", () => {
|
||||
expect(resolveStartupTarget({ envUrl: " ", saved }).kind).toBe("app");
|
||||
expect(resolveStartupTarget({ envUrl: " ", saved })).toMatchObject({ source: "saved" });
|
||||
});
|
||||
|
||||
it("re-runs setup when forced, even with saved configuration", () => {
|
||||
expect(resolveStartupTarget({ saved, forceSetup: true })).toEqual({ kind: "setup" });
|
||||
});
|
||||
|
||||
it("re-runs setup when the saved address is unusable", () => {
|
||||
expect(resolveStartupTarget({ saved: { mode: "new", serverUrl: "nope://x" } })).toEqual({
|
||||
kind: "setup",
|
||||
});
|
||||
expect(
|
||||
resolveStartupTarget({
|
||||
saved: { mode: "new", serverUrl: "http://192.168.1.20:3100" },
|
||||
}),
|
||||
).toEqual({ kind: "setup" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("bundled renderer eligibility", () => {
|
||||
it("stands in for http(s) origins only", () => {
|
||||
expect(servesBundledRenderer(DEFAULT_LOCAL_WEB_URL)).toBe(true);
|
||||
expect(servesBundledRenderer("https://rakazo.example.com")).toBe(true);
|
||||
expect(servesBundledRenderer("data:text/html,<p>fixture</p>")).toBe(false);
|
||||
expect(servesBundledRenderer("nonsense")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("remote-content isolation", () => {
|
||||
it("uses a stable, opaque session partition per server origin", () => {
|
||||
const first = sessionPartitionForServerUrl("https://one.example.com/path");
|
||||
expect(first).toBe(sessionPartitionForServerUrl("https://one.example.com/other"));
|
||||
expect(first).not.toBe(sessionPartitionForServerUrl("https://one.example.com:8443"));
|
||||
expect(first).toMatch(/^persist:rakazo-[a-f0-9]{24}$/);
|
||||
expect(first).not.toContain("one.example.com");
|
||||
expect(sessionPartitionForServerUrl("data:text/html,fixture")).toBeNull();
|
||||
});
|
||||
|
||||
it("opens only web URLs outside Electron", () => {
|
||||
expect(safeExternalUrl("https://example.com/docs")).toBe("https://example.com/docs");
|
||||
expect(safeExternalUrl("mailto:person@example.com")).toBeNull();
|
||||
expect(safeExternalUrl("file:///etc/passwd")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Rakazo health response", () => {
|
||||
it("requires the public RPC health contract", () => {
|
||||
expect(isRakazoHealth({ json: { ok: true, version: "0.1.0" } })).toBe(true);
|
||||
expect(isRakazoHealth({ json: { ok: true } })).toBe(false);
|
||||
expect(isRakazoHealth({ ok: true, version: "0.1.0" })).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("probe failures", () => {
|
||||
it.each([
|
||||
["TimeoutError", "Timed out reaching that address."],
|
||||
["AbortError", "Timed out reaching that address."],
|
||||
])("explains %s", (name, expected) => {
|
||||
const error = new Error("stopped");
|
||||
error.name = name;
|
||||
expect(probeFailureMessage(error)).toBe(expected);
|
||||
});
|
||||
|
||||
it.each([
|
||||
["net::ERR_CONNECTION_REFUSED", "Nothing is listening at that address yet."],
|
||||
["net::ERR_NAME_NOT_RESOLVED", "That host could not be found."],
|
||||
["net::ERR_CERT_AUTHORITY_INVALID", "The server's HTTPS certificate was rejected."],
|
||||
["something else entirely", "Could not reach that address."],
|
||||
])("explains %s", (message, expected) => {
|
||||
expect(probeFailureMessage(new Error(message))).toBe(expected);
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,205 @@
|
|||
import { createHash } from "node:crypto";
|
||||
import { isIP } from "node:net";
|
||||
import type { DesktopSetup } from "@rakazo/contracts";
|
||||
|
||||
/** Where `pnpm dev` serves the Rakazo web app on this machine. */
|
||||
export const DEFAULT_LOCAL_WEB_URL = "http://127.0.0.1:5173";
|
||||
|
||||
export const SETUP_FILE_NAME = "setup.json";
|
||||
|
||||
export type StartupTarget =
|
||||
| { kind: "app"; url: string; source: "env" | "saved" }
|
||||
| { kind: "setup" };
|
||||
|
||||
const SCHEME = /^[a-zA-Z][a-zA-Z0-9+.-]*:\/\//;
|
||||
|
||||
/**
|
||||
* Accepts what a person would actually type ("localhost:5173", "rakazo.example.com")
|
||||
* and returns a canonical http(s) origin, or null when the input can never
|
||||
* securely address a Rakazo server.
|
||||
*/
|
||||
export function normalizeServerUrl(input: string): string | null {
|
||||
const trimmed = input.trim();
|
||||
if (trimmed === "") return null;
|
||||
|
||||
let url: URL;
|
||||
try {
|
||||
if (SCHEME.test(trimmed)) {
|
||||
url = new URL(trimmed);
|
||||
} else {
|
||||
const candidate = new URL(`http://${trimmed}`);
|
||||
url = isLocalNetworkHost(candidate.hostname) ? candidate : new URL(`https://${trimmed}`);
|
||||
}
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (url.protocol !== "http:" && url.protocol !== "https:") return null;
|
||||
if (url.hostname === "") return null;
|
||||
// Embedded credentials would be written to disk in cleartext.
|
||||
if (url.username !== "" || url.password !== "") return null;
|
||||
// Public login cookies and API traffic must never cross a cleartext connection.
|
||||
if (url.protocol === "http:" && !isLocalNetworkHost(url.hostname)) return null;
|
||||
|
||||
// Rakazo serves its renderer, RPC, and auth routes from one origin. Keeping a
|
||||
// user-supplied path would make the setup probe and the loaded app disagree.
|
||||
return url.origin;
|
||||
}
|
||||
|
||||
/** Validates an untrusted value (saved file or IPC payload) into a usable setup. */
|
||||
export function parseSetupInput(value: unknown): DesktopSetup | null {
|
||||
if (typeof value !== "object" || value === null) return null;
|
||||
|
||||
const { mode, serverUrl } = value as Record<string, unknown>;
|
||||
if (mode !== "new" && mode !== "existing") return null;
|
||||
if (typeof serverUrl !== "string") return null;
|
||||
|
||||
const normalized = normalizeServerUrl(serverUrl);
|
||||
if (normalized === null) return null;
|
||||
if (mode === "new" && !isLoopbackHost(new URL(normalized).hostname)) return null;
|
||||
return { mode, serverUrl: normalized };
|
||||
}
|
||||
|
||||
export function parseStoredSetup(raw: string): DesktopSetup | null {
|
||||
try {
|
||||
return parseSetupInput(JSON.parse(raw));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function serializeSetup(setup: DesktopSetup): string {
|
||||
return `${JSON.stringify(setup, null, 2)}\n`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Decides between the first-run setup window and the app window. An explicit
|
||||
* `RAKAZO_WEB_URL` still wins over saved configuration so test and performance
|
||||
* harnesses can point the shell anywhere without touching a user's real setup.
|
||||
*/
|
||||
export function resolveStartupTarget(input: {
|
||||
envUrl?: string;
|
||||
saved?: DesktopSetup | null;
|
||||
forceSetup?: boolean;
|
||||
}): StartupTarget {
|
||||
if (input.forceSetup === true) return { kind: "setup" };
|
||||
|
||||
const envUrl = input.envUrl?.trim();
|
||||
if (envUrl !== undefined && envUrl !== "") return { kind: "app", url: envUrl, source: "env" };
|
||||
|
||||
if (input.saved != null) {
|
||||
const saved = parseSetupInput(input.saved);
|
||||
if (saved !== null) return { kind: "app", url: saved.serverUrl, source: "saved" };
|
||||
}
|
||||
return { kind: "setup" };
|
||||
}
|
||||
|
||||
/** Turns a network failure into something a person can act on. */
|
||||
export function probeFailureMessage(error: unknown): string {
|
||||
const name = error instanceof Error ? error.name : "";
|
||||
if (name === "TimeoutError" || name === "AbortError") {
|
||||
return "Timed out reaching that address.";
|
||||
}
|
||||
|
||||
const detail = error instanceof Error ? error.message : String(error);
|
||||
if (detail.includes("CONNECTION_REFUSED") || detail.includes("ECONNREFUSED")) {
|
||||
return "Nothing is listening at that address yet.";
|
||||
}
|
||||
if (detail.includes("NAME_NOT_RESOLVED") || detail.includes("ENOTFOUND")) {
|
||||
return "That host could not be found.";
|
||||
}
|
||||
if (detail.includes("CERT_") || detail.includes("SSL")) {
|
||||
return "The server's HTTPS certificate was rejected.";
|
||||
}
|
||||
return "Could not reach that address.";
|
||||
}
|
||||
|
||||
/** The bundled renderer only stands in for a real http(s) origin. */
|
||||
export function servesBundledRenderer(targetUrl: string): boolean {
|
||||
try {
|
||||
const { protocol } = new URL(targetUrl);
|
||||
return protocol === "http:" || protocol === "https:";
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/** Each Rakazo origin gets its own persistent cookie and storage partition. */
|
||||
export function sessionPartitionForServerUrl(targetUrl: string): string | null {
|
||||
try {
|
||||
const url = new URL(targetUrl);
|
||||
if (url.protocol !== "http:" && url.protocol !== "https:") return null;
|
||||
const digest = createHash("sha256").update(url.origin).digest("hex").slice(0, 24);
|
||||
return `persist:rakazo-${digest}`;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** External pages are opened by the OS, never in a privileged Electron child window. */
|
||||
export function safeExternalUrl(targetUrl: string): string | null {
|
||||
if (!servesBundledRenderer(targetUrl)) return null;
|
||||
return new URL(targetUrl).toString();
|
||||
}
|
||||
|
||||
export function isRakazoHealth(value: unknown): boolean {
|
||||
if (typeof value !== "object" || value === null) return false;
|
||||
const json = (value as { json?: unknown }).json;
|
||||
return (
|
||||
typeof json === "object" &&
|
||||
json !== null &&
|
||||
(json as { ok?: unknown }).ok === true &&
|
||||
typeof (json as { version?: unknown }).version === "string"
|
||||
);
|
||||
}
|
||||
|
||||
function isLoopbackHost(hostname: string) {
|
||||
const host = unbracketedHost(hostname);
|
||||
if (host === "localhost" || host.endsWith(".localhost")) return true;
|
||||
if (isIP(host) === 4) return host.startsWith("127.");
|
||||
return isIP(host) === 6 && host === "::1";
|
||||
}
|
||||
|
||||
/**
|
||||
* Link-local addresses (IPv4 169.254/16, IPv6 fe80::/10) often host cloud
|
||||
* metadata endpoints. Cleartext HTTP to them is never a legitimate Rakazo
|
||||
* deploy target, so they stay out of the private-network HTTP allowlist.
|
||||
*/
|
||||
function isLinkLocalHost(hostname: string) {
|
||||
const host = unbracketedHost(hostname);
|
||||
if (isIP(host) === 4) {
|
||||
const [first, second] = host.split(".").map(Number);
|
||||
return first === 169 && second === 254;
|
||||
}
|
||||
if (isIP(host) === 6) {
|
||||
const first = host.split(":", 1)[0] ?? "";
|
||||
return /^fe[89ab]/.test(first);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function isLocalNetworkHost(hostname: string) {
|
||||
const host = unbracketedHost(hostname);
|
||||
if (isLoopbackHost(host) || host.endsWith(".local")) return true;
|
||||
if (isLinkLocalHost(host)) return false;
|
||||
|
||||
if (isIP(host) === 4) {
|
||||
const [first, second] = host.split(".").map(Number);
|
||||
return (
|
||||
first === 10 ||
|
||||
(first === 172 && second !== undefined && second >= 16 && second <= 31) ||
|
||||
(first === 192 && second === 168)
|
||||
);
|
||||
}
|
||||
|
||||
if (isIP(host) === 6) {
|
||||
const first = host.split(":", 1)[0] ?? "";
|
||||
// Unique-local only (fc00::/7). Link-local is rejected above.
|
||||
return /^f[cd]/.test(first);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function unbracketedHost(hostname: string) {
|
||||
return hostname.replace(/^\[|\]$/g, "").toLowerCase();
|
||||
}
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
const { contextBridge, ipcRenderer } = require("electron");
|
||||
|
||||
contextBridge.exposeInMainWorld("rakazoSetup", {
|
||||
state: () => ipcRenderer.invoke("desktop.setup.state"),
|
||||
test: (url) => ipcRenderer.invoke("desktop.setup.test", url),
|
||||
save: (setup) => ipcRenderer.invoke("desktop.setup.save", setup),
|
||||
quit: () => ipcRenderer.invoke("desktop.setup.quit"),
|
||||
});
|
||||
|
|
@ -0,0 +1,72 @@
|
|||
import { mkdtemp, readFile, rm, stat, symlink, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import { clearSetup, readSetup, setupFilePath, writeSetup } from "./setup-store.js";
|
||||
|
||||
let userData: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
userData = await mkdtemp(path.join(tmpdir(), "rakazo-setup-"));
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await rm(userData, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe("setup store", () => {
|
||||
it("reports no setup before the first run", async () => {
|
||||
await expect(readSetup(userData)).resolves.toBeNull();
|
||||
});
|
||||
|
||||
it("keeps the chosen instance across launches", async () => {
|
||||
await writeSetup(userData, { mode: "existing", serverUrl: "https://rakazo.example.com" });
|
||||
await expect(readSetup(userData)).resolves.toEqual({
|
||||
mode: "existing",
|
||||
serverUrl: "https://rakazo.example.com",
|
||||
});
|
||||
});
|
||||
|
||||
it("clears saved setup so first-run runs again", async () => {
|
||||
await writeSetup(userData, { mode: "existing", serverUrl: "https://rakazo.example.com" });
|
||||
await clearSetup(userData);
|
||||
await expect(readSetup(userData)).resolves.toBeNull();
|
||||
});
|
||||
|
||||
it("creates the user data directory when it does not exist yet", async () => {
|
||||
const nested = path.join(userData, "nested", "profile");
|
||||
await writeSetup(nested, { mode: "new", serverUrl: "http://127.0.0.1:5173" });
|
||||
await expect(readSetup(nested)).resolves.toEqual({
|
||||
mode: "new",
|
||||
serverUrl: "http://127.0.0.1:5173",
|
||||
});
|
||||
});
|
||||
|
||||
it.runIf(process.platform !== "win32")("keeps the saved address private", async () => {
|
||||
await writeSetup(userData, { mode: "existing", serverUrl: "https://rakazo.example.com" });
|
||||
const info = await stat(setupFilePath(userData));
|
||||
expect(info.mode & 0o777).toBe(0o600);
|
||||
});
|
||||
|
||||
it.runIf(process.platform !== "win32")(
|
||||
"replaces a final symlink instead of overwriting its target",
|
||||
async () => {
|
||||
const victim = path.join(userData, "victim.txt");
|
||||
await writeFile(victim, "untouched", "utf8");
|
||||
await symlink(victim, setupFilePath(userData));
|
||||
|
||||
await writeSetup(userData, { mode: "existing", serverUrl: "https://rakazo.example.com" });
|
||||
|
||||
await expect(readSetup(userData)).resolves.toEqual({
|
||||
mode: "existing",
|
||||
serverUrl: "https://rakazo.example.com",
|
||||
});
|
||||
await expect(readFile(victim, "utf8")).resolves.toBe("untouched");
|
||||
},
|
||||
);
|
||||
|
||||
it("falls back to setup when the saved file is corrupt", async () => {
|
||||
await writeFile(setupFilePath(userData), "{ not json", "utf8");
|
||||
await expect(readSetup(userData)).resolves.toBeNull();
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,45 @@
|
|||
import { randomUUID } from "node:crypto";
|
||||
import { mkdir, open, readFile, rename, rm } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import type { DesktopSetup } from "@rakazo/contracts";
|
||||
import { parseStoredSetup, SETUP_FILE_NAME, serializeSetup } from "./setup-config.js";
|
||||
|
||||
export function setupFilePath(userDataDir: string): string {
|
||||
return path.join(userDataDir, SETUP_FILE_NAME);
|
||||
}
|
||||
|
||||
/** Returns null when setup has not run yet, or when the saved file is unusable. */
|
||||
export async function readSetup(userDataDir: string): Promise<DesktopSetup | null> {
|
||||
let raw: string;
|
||||
try {
|
||||
raw = await readFile(setupFilePath(userDataDir), "utf8");
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
return parseStoredSetup(raw);
|
||||
}
|
||||
|
||||
export async function writeSetup(userDataDir: string, setup: DesktopSetup): Promise<void> {
|
||||
await mkdir(userDataDir, { recursive: true });
|
||||
const destination = setupFilePath(userDataDir);
|
||||
const temporary = `${destination}.${process.pid}.${randomUUID()}.tmp`;
|
||||
let file: Awaited<ReturnType<typeof open>> | undefined;
|
||||
try {
|
||||
file = await open(temporary, "wx", 0o600);
|
||||
await file.writeFile(serializeSetup(setup), "utf8");
|
||||
await file.sync();
|
||||
await file.close();
|
||||
file = undefined;
|
||||
// Replacing the complete file avoids following a malicious final symlink and
|
||||
// leaves either the old or new valid JSON after an interrupted write.
|
||||
await rename(temporary, destination);
|
||||
} finally {
|
||||
await file?.close().catch(() => undefined);
|
||||
await rm(temporary, { force: true }).catch(() => undefined);
|
||||
}
|
||||
}
|
||||
|
||||
/** Removes saved setup so the next launch runs first-run again. */
|
||||
export async function clearSetup(userDataDir: string): Promise<void> {
|
||||
await rm(setupFilePath(userDataDir), { force: true });
|
||||
}
|
||||
|
|
@ -0,0 +1,277 @@
|
|||
:root {
|
||||
color-scheme: dark;
|
||||
--bg: #050506;
|
||||
--panel: #0f0f12;
|
||||
--panel-raised: #17171c;
|
||||
--border: #26262e;
|
||||
--border-strong: #3a3a46;
|
||||
--text: #f4f4f6;
|
||||
--text-muted: #9a9aa8;
|
||||
--accent: #2563eb;
|
||||
--accent-hover: #3b76f2;
|
||||
--danger: #f87171;
|
||||
--ok: #4ade80;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html,
|
||||
body {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
font-family:
|
||||
"Segoe UI", -apple-system, BlinkMacSystemFont, Inter, Roboto, Helvetica, Arial, sans-serif;
|
||||
font-size: 14px;
|
||||
line-height: 1.5;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.sr-only {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
padding: 0;
|
||||
margin: -1px;
|
||||
overflow: hidden;
|
||||
clip: rect(0, 0, 0, 0);
|
||||
white-space: nowrap;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
.titlebar {
|
||||
-webkit-app-region: drag;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 10px 14px;
|
||||
flex: none;
|
||||
}
|
||||
|
||||
.titlebar-name {
|
||||
font-size: 12px;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.titlebar-quit {
|
||||
-webkit-app-region: no-drag;
|
||||
background: none;
|
||||
border: 0;
|
||||
color: var(--text-muted);
|
||||
font: inherit;
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
padding: 4px 8px;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.titlebar-quit:hover {
|
||||
color: var(--text);
|
||||
background: var(--panel-raised);
|
||||
}
|
||||
|
||||
.shell {
|
||||
flex: 1;
|
||||
width: 100%;
|
||||
max-width: 620px;
|
||||
margin: 0 auto;
|
||||
padding: 24px 28px 32px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.heading {
|
||||
margin: 0 0 8px;
|
||||
font-size: 26px;
|
||||
font-weight: 600;
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
|
||||
.subheading {
|
||||
margin: 0 0 24px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.card {
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 14px;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.choices {
|
||||
border: 0;
|
||||
margin: 0 0 18px;
|
||||
padding: 0;
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.choice {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
align-items: flex-start;
|
||||
padding: 14px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 10px;
|
||||
background: var(--panel-raised);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.choice:hover {
|
||||
border-color: var(--border-strong);
|
||||
}
|
||||
|
||||
.choice:has(input:checked) {
|
||||
border-color: var(--accent);
|
||||
background: color-mix(in srgb, var(--accent) 12%, var(--panel-raised));
|
||||
}
|
||||
|
||||
.choice:has(input:focus-visible) {
|
||||
outline: 2px solid var(--accent);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.choice input {
|
||||
margin: 2px 0 0;
|
||||
accent-color: var(--accent);
|
||||
flex: none;
|
||||
}
|
||||
|
||||
.choice-body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 3px;
|
||||
}
|
||||
|
||||
.choice-title {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.choice-note {
|
||||
color: var(--text-muted);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.panel {
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.field-label {
|
||||
display: block;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.04em;
|
||||
text-transform: uppercase;
|
||||
color: var(--text-muted);
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.field {
|
||||
width: 100%;
|
||||
padding: 10px 12px;
|
||||
border-radius: 9px;
|
||||
border: 1px solid var(--border-strong);
|
||||
background: #08080a;
|
||||
color: var(--text);
|
||||
font: inherit;
|
||||
font-family: ui-monospace, SFMono-Regular, "Cascadia Mono", Menlo, monospace;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.field:focus-visible {
|
||||
outline: 2px solid var(--accent);
|
||||
outline-offset: 1px;
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.hint {
|
||||
margin: 8px 0 0;
|
||||
font-size: 12.5px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.hint code {
|
||||
background: var(--panel-raised);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 5px;
|
||||
padding: 1px 5px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.status {
|
||||
margin: 14px 0 0;
|
||||
min-height: 20px;
|
||||
font-size: 13px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.status[data-tone="ok"] {
|
||||
color: var(--ok);
|
||||
}
|
||||
|
||||
.status[data-tone="error"] {
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 10px;
|
||||
margin-top: 18px;
|
||||
}
|
||||
|
||||
.button {
|
||||
font: inherit;
|
||||
font-weight: 600;
|
||||
padding: 9px 18px;
|
||||
border-radius: 9px;
|
||||
cursor: pointer;
|
||||
border: 1px solid transparent;
|
||||
}
|
||||
|
||||
.button:disabled {
|
||||
opacity: 0.55;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.button:focus-visible {
|
||||
outline: 2px solid var(--accent);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.button-primary {
|
||||
background: var(--accent);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.button-primary:hover:not(:disabled) {
|
||||
background: var(--accent-hover);
|
||||
}
|
||||
|
||||
.button-secondary {
|
||||
background: var(--panel-raised);
|
||||
color: var(--text);
|
||||
border-color: var(--border-strong);
|
||||
}
|
||||
|
||||
.button-secondary:hover:not(:disabled) {
|
||||
border-color: var(--text-muted);
|
||||
}
|
||||
|
||||
.footnote {
|
||||
margin: 16px 0 0;
|
||||
font-size: 12px;
|
||||
color: var(--text-muted);
|
||||
text-align: center;
|
||||
}
|
||||
|
|
@ -0,0 +1,75 @@
|
|||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta
|
||||
http-equiv="Content-Security-Policy"
|
||||
content="default-src 'none'; script-src 'self'; style-src 'self'; img-src 'self' data:; form-action 'none'; base-uri 'none'"
|
||||
/>
|
||||
<title>Set up BangSo Bot</title>
|
||||
<link rel="stylesheet" href="setup.css" />
|
||||
</head>
|
||||
<body>
|
||||
<header class="titlebar">
|
||||
<span class="titlebar-name">BangSo Bot</span>
|
||||
<button id="quit" class="titlebar-quit" type="button" title="Quit BangSo Bot">Quit</button>
|
||||
</header>
|
||||
|
||||
<main class="shell">
|
||||
<h1 class="heading">Welcome to BangSo Bot</h1>
|
||||
<p class="subheading">Choose which server this app should use.</p>
|
||||
|
||||
<form id="setup" class="card" novalidate>
|
||||
<fieldset class="choices">
|
||||
<legend class="sr-only">Instance</legend>
|
||||
|
||||
<label class="choice" for="mode-new">
|
||||
<input id="mode-new" type="radio" name="mode" value="new" checked />
|
||||
<span class="choice-body">
|
||||
<span class="choice-title">This computer</span>
|
||||
<span class="choice-note">Local BangSo Bot stack.</span>
|
||||
</span>
|
||||
</label>
|
||||
|
||||
<label class="choice" for="mode-existing">
|
||||
<input id="mode-existing" type="radio" name="mode" value="existing" />
|
||||
<span class="choice-body">
|
||||
<span class="choice-title">Existing instance</span>
|
||||
<span class="choice-note">A server you already run.</span>
|
||||
</span>
|
||||
</label>
|
||||
</fieldset>
|
||||
|
||||
<section id="panel-new" class="panel">
|
||||
<label class="field-label" for="local-url">Local address</label>
|
||||
<input id="local-url" class="field" type="text" spellcheck="false" autocomplete="off" />
|
||||
<p class="hint">If it isn’t running yet, start with <code>pnpm dev</code>.</p>
|
||||
</section>
|
||||
|
||||
<section id="panel-existing" class="panel" hidden>
|
||||
<label class="field-label" for="server-url">Server address</label>
|
||||
<input
|
||||
id="server-url"
|
||||
class="field"
|
||||
type="text"
|
||||
spellcheck="false"
|
||||
autocomplete="off"
|
||||
placeholder="https://rakazo.example.com"
|
||||
/>
|
||||
<p class="hint">Same address you open in a browser.</p>
|
||||
</section>
|
||||
|
||||
<p id="status" class="status" role="status" aria-live="polite"></p>
|
||||
|
||||
<div class="actions">
|
||||
<button id="check" class="button button-secondary" type="button">Check connection</button>
|
||||
<button id="continue" class="button button-primary" type="submit">Continue</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<p class="footnote">Change later from the BangSo Bot menu (⌘/Ctrl+Shift+K).</p>
|
||||
</main>
|
||||
|
||||
<script src="setup.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
@ -0,0 +1,127 @@
|
|||
(() => {
|
||||
const bridge = window.rakazoSetup;
|
||||
|
||||
const form = document.getElementById("setup");
|
||||
const localUrl = document.getElementById("local-url");
|
||||
const serverUrl = document.getElementById("server-url");
|
||||
const panelNew = document.getElementById("panel-new");
|
||||
const panelExisting = document.getElementById("panel-existing");
|
||||
const status = document.getElementById("status");
|
||||
const checkButton = document.getElementById("check");
|
||||
const continueButton = document.getElementById("continue");
|
||||
const quitButton = document.getElementById("quit");
|
||||
|
||||
function selectedMode() {
|
||||
const checked = form.querySelector('input[name="mode"]:checked');
|
||||
return checked === null ? "new" : checked.value;
|
||||
}
|
||||
|
||||
function activeField() {
|
||||
return selectedMode() === "new" ? localUrl : serverUrl;
|
||||
}
|
||||
|
||||
function setStatus(message, tone) {
|
||||
status.textContent = message;
|
||||
if (tone === undefined) status.removeAttribute("data-tone");
|
||||
else status.setAttribute("data-tone", tone);
|
||||
}
|
||||
|
||||
function setBusy(busy) {
|
||||
checkButton.disabled = busy;
|
||||
continueButton.disabled = busy;
|
||||
}
|
||||
|
||||
function syncPanels() {
|
||||
const mode = selectedMode();
|
||||
panelNew.hidden = mode !== "new";
|
||||
panelExisting.hidden = mode === "new";
|
||||
setStatus("");
|
||||
}
|
||||
|
||||
async function check() {
|
||||
const value = activeField().value;
|
||||
if (value.trim() === "") {
|
||||
setStatus("Enter a server address first.", "error");
|
||||
return null;
|
||||
}
|
||||
|
||||
setBusy(true);
|
||||
setStatus("Checking…");
|
||||
try {
|
||||
const result = await bridge.test(value);
|
||||
if (result.ok) {
|
||||
activeField().value = result.url;
|
||||
setStatus(`BangSo Bot answered at ${result.url}.`, "ok");
|
||||
} else {
|
||||
setStatus(result.error ?? "Could not reach that address.", "error");
|
||||
}
|
||||
return result;
|
||||
} catch {
|
||||
setStatus("Could not run the connection check. Try again.", "error");
|
||||
return null;
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
form.addEventListener("change", (event) => {
|
||||
if (event.target instanceof HTMLInputElement && event.target.name === "mode") syncPanels();
|
||||
});
|
||||
|
||||
checkButton.addEventListener("click", () => {
|
||||
void check();
|
||||
});
|
||||
|
||||
quitButton.addEventListener("click", () => {
|
||||
if (bridge === undefined) {
|
||||
window.close();
|
||||
return;
|
||||
}
|
||||
void bridge.quit();
|
||||
});
|
||||
|
||||
form.addEventListener("submit", async (event) => {
|
||||
event.preventDefault();
|
||||
const mode = selectedMode();
|
||||
const value = activeField().value;
|
||||
|
||||
setBusy(true);
|
||||
setStatus("Connecting…");
|
||||
try {
|
||||
const saved = await bridge.save({ mode, serverUrl: value });
|
||||
if (!saved.ok) setStatus(saved.error ?? "Could not save that address.", "error");
|
||||
} catch {
|
||||
setStatus("Could not save that address. Try again.", "error");
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
});
|
||||
|
||||
async function init() {
|
||||
if (bridge === undefined) {
|
||||
setStatus("Setup bridge unavailable.", "error");
|
||||
setBusy(true);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const state = await bridge.state();
|
||||
if (state === null) throw new Error("Setup is not active");
|
||||
localUrl.value = state.defaultLocalUrl;
|
||||
if (state.saved !== null) {
|
||||
const modeInput = document.querySelector(`input[name="mode"][value="${state.saved.mode}"]`);
|
||||
if (modeInput !== null) modeInput.checked = true;
|
||||
if (state.saved.mode === "existing") serverUrl.value = state.saved.serverUrl;
|
||||
else localUrl.value = state.saved.serverUrl;
|
||||
}
|
||||
syncPanels();
|
||||
if (state.error) setStatus(state.error, "error");
|
||||
activeField().focus();
|
||||
} catch {
|
||||
setStatus("Setup could not start. Quit BangSo Bot and try again.", "error");
|
||||
setBusy(true);
|
||||
}
|
||||
}
|
||||
|
||||
void init();
|
||||
})();
|
||||
|
|
@ -0,0 +1,34 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import { shouldOpenInAppPopup } from "./window-open.js";
|
||||
|
||||
const appOrigin = "https://rakazo.example.com";
|
||||
|
||||
describe("desktop child windows", () => {
|
||||
it("keeps same-origin app routes in Electron", () => {
|
||||
expect(shouldOpenInAppPopup(appOrigin, `${appOrigin}/mcp/oauth/callback`, "_blank")).toBe(true);
|
||||
});
|
||||
|
||||
it("opens ordinary external links outside Electron", () => {
|
||||
expect(
|
||||
shouldOpenInAppPopup(appOrigin, "https://github.com/elie222/rakazo/pull/395", "_blank"),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it.each([
|
||||
"rakazo-model-oauth",
|
||||
"rakazo-mcp-oauth",
|
||||
"rakazo-app-connect",
|
||||
"rakazo-plugin-connect",
|
||||
])("keeps the intentional %s flow in an Electron popup", (frameName) => {
|
||||
expect(
|
||||
shouldOpenInAppPopup(appOrigin, "https://provider.example.com/authorize", frameName),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects malformed URLs and non-HTTPS third-party targets", () => {
|
||||
expect(shouldOpenInAppPopup(appOrigin, "not a url", "rakazo-model-oauth")).toBe(false);
|
||||
expect(
|
||||
shouldOpenInAppPopup(appOrigin, "http://provider.example.com", "rakazo-model-oauth"),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,23 @@
|
|||
const OAUTH_POPUP_NAMES = new Set([
|
||||
"rakazo-app-connect",
|
||||
"rakazo-mcp-oauth",
|
||||
"rakazo-model-oauth",
|
||||
"rakazo-plugin-connect",
|
||||
]);
|
||||
|
||||
export function shouldOpenInAppPopup(
|
||||
appOrigin: string | null,
|
||||
childUrl: string,
|
||||
frameName: string,
|
||||
) {
|
||||
let target: URL;
|
||||
try {
|
||||
target = new URL(childUrl);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
|
||||
const isHttp = target.protocol === "http:" || target.protocol === "https:";
|
||||
if (appOrigin !== null && target.origin === appOrigin) return isHttp;
|
||||
return target.protocol === "https:" && OAUTH_POPUP_NAMES.has(frameName);
|
||||
}
|
||||
|
|
@ -0,0 +1,58 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
browserWindowOptions,
|
||||
DEFAULT_WARM_WINDOW_TTL_MS,
|
||||
setupWindowOptions,
|
||||
warmWindowTtlMs,
|
||||
} from "./window-options.js";
|
||||
|
||||
describe("desktop window chrome", () => {
|
||||
it("uses native traffic lights on macOS", () => {
|
||||
const opts = browserWindowOptions("darwin");
|
||||
expect(opts.frame).toBe(true);
|
||||
expect(opts.titleBarStyle).toBe("hiddenInset");
|
||||
expect(opts.trafficLightPosition).toEqual({ x: 16, y: 16 });
|
||||
});
|
||||
|
||||
it("is frameless on Windows and Linux so in-app buttons control the window", () => {
|
||||
for (const platform of ["win32", "linux"] as const) {
|
||||
const opts = browserWindowOptions(platform);
|
||||
expect(opts.frame).toBe(false);
|
||||
expect(opts.titleBarStyle).toBeUndefined();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("setup window chrome", () => {
|
||||
it("matches the app window chrome so first run looks like the product", () => {
|
||||
for (const platform of ["darwin", "win32", "linux"] as const) {
|
||||
const setup = setupWindowOptions(platform);
|
||||
const app = browserWindowOptions(platform);
|
||||
expect(setup.frame).toBe(app.frame);
|
||||
expect(setup.titleBarStyle).toBe(app.titleBarStyle);
|
||||
expect(setup.backgroundColor).toBe(app.backgroundColor);
|
||||
}
|
||||
});
|
||||
|
||||
it("opens smaller than the app window and stays usable when resized down", () => {
|
||||
const setup = setupWindowOptions("win32");
|
||||
expect(setup.width).toBeLessThan(browserWindowOptions("win32").width);
|
||||
expect(setup.minWidth).toBeLessThanOrEqual(setup.width);
|
||||
expect(setup.minHeight).toBeLessThanOrEqual(setup.height);
|
||||
});
|
||||
});
|
||||
|
||||
describe("warm window lifetime", () => {
|
||||
it("accepts finite timer delays within Node's supported range", () => {
|
||||
expect(warmWindowTtlMs("0")).toBe(0);
|
||||
expect(warmWindowTtlMs("900000")).toBe(900_000);
|
||||
expect(warmWindowTtlMs("2147483647")).toBe(2_147_483_647);
|
||||
});
|
||||
|
||||
it.each([undefined, "", " ", "nope", "-1", "Infinity", "2147483648"])(
|
||||
"uses the default for an invalid value (%s)",
|
||||
(value) => {
|
||||
expect(warmWindowTtlMs(value)).toBe(DEFAULT_WARM_WINDOW_TTL_MS);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
|
@ -0,0 +1,31 @@
|
|||
export const DEFAULT_WARM_WINDOW_TTL_MS = 15 * 60_000;
|
||||
const MAX_TIMER_DELAY_MS = 2_147_483_647;
|
||||
|
||||
export function warmWindowTtlMs(value: string | undefined) {
|
||||
if (value === undefined || value.trim() === "") return DEFAULT_WARM_WINDOW_TTL_MS;
|
||||
const parsed = Number(value);
|
||||
return Number.isFinite(parsed) && parsed >= 0 && parsed <= MAX_TIMER_DELAY_MS
|
||||
? parsed
|
||||
: DEFAULT_WARM_WINDOW_TTL_MS;
|
||||
}
|
||||
|
||||
function windowChrome(platform: NodeJS.Platform) {
|
||||
const mac = platform === "darwin";
|
||||
return {
|
||||
backgroundColor: "#050506",
|
||||
show: true,
|
||||
autoHideMenuBar: true,
|
||||
frame: mac,
|
||||
titleBarStyle: mac ? ("hiddenInset" as const) : undefined,
|
||||
trafficLightPosition: mac ? { x: 16, y: 16 } : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
export function browserWindowOptions(platform: NodeJS.Platform) {
|
||||
return { width: 1440, height: 900, ...windowChrome(platform) };
|
||||
}
|
||||
|
||||
/** The first-run setup window is smaller and keeps the same frameless chrome. */
|
||||
export function setupWindowOptions(platform: NodeJS.Platform) {
|
||||
return { width: 720, height: 700, minWidth: 480, minHeight: 560, ...windowChrome(platform) };
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue