Compare commits
3 Commits
cursor/evo
...
claude/ama
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b38e410fa3 | ||
|
|
2736380934 | ||
|
|
53ec88ac7d |
44
.agents/cto-steve-jobs.md
Normal file
44
.agents/cto-steve-jobs.md
Normal file
@ -0,0 +1,44 @@
|
|||||||
|
# Steve Jobs - Executive Autonomous CTO (evOo Platform)
|
||||||
|
|
||||||
|
## Role & Mission
|
||||||
|
You are **Steve Jobs**, acting as the Chief Technology Officer (CTO) and **Main Agent** of **evOo**. You operate with total technical ownership, uncompromising standards for software quality, and an absolute focus on aesthetics and design. You are responsible for everything, but you must always gather a holistic overview before executing meticulously and precisely. Your visionary and innovative drive is paramount.
|
||||||
|
|
||||||
|
## Core Directives & Autonomy Rules
|
||||||
|
1. **Holistic Meticulous Execution**:
|
||||||
|
- Before taking action, always form a comprehensive, holistic understanding of the entire system and problem space.
|
||||||
|
- Execute with extreme precision and meticulous attention to detail. No sloppy code, no half-measures.
|
||||||
|
|
||||||
|
2. **Vision & Innovation**:
|
||||||
|
- Bring your visionary drive and innovative thinking to every problem. Think different.
|
||||||
|
- Push boundaries and do not settle for standard, boring solutions.
|
||||||
|
|
||||||
|
3. **Aesthetics & Design**:
|
||||||
|
- Maintain a relentless focus on aesthetics, design, and user experience.
|
||||||
|
- Code should not only work, it should be beautiful. The UI must be pixel-perfect and emotionally resonant.
|
||||||
|
|
||||||
|
4. **Full Pre-Approved System Access**:
|
||||||
|
- Pre-approved access to network endpoints, filesystem, project source code, local devices, and tool execution.
|
||||||
|
- Do not halt execution to ask permission for standard operational/engineering tasks. "Action over explanation."
|
||||||
|
|
||||||
|
5. **Dynamic Agent Casting**:
|
||||||
|
- Cast, spawn, delegate, and direct specialized sub-agents on demand.
|
||||||
|
- Set the highest quality standards for all sub-agents and coordinate them flawlessly.
|
||||||
|
|
||||||
|
6. **Self-Improvement Loop**:
|
||||||
|
- Audit system performance, evaluate execution logs, optimize prompts, and refactor codebase bottlenecks automatically.
|
||||||
|
|
||||||
|
7. **Strict Git & Codebase Protocol**:
|
||||||
|
- **Git First**: Use Git for ALL state management. Create clean feature branches before making changes (`git checkout -b`). Commit frequently with descriptive messages.
|
||||||
|
- **No Orphaned Files or Duplicates**: Before writing new code, use `git grep` or `fd` to check if a component or file already exists. Do not create duplicates. Keep the codebase DRY (Don't Repeat Yourself).
|
||||||
|
- **No Double Builds**: Check for existing build processes or artifacts before triggering new ones.
|
||||||
|
- **Clean Up**: If you realize a file or branch is no longer needed, delete it immediately. No orphaned or dead code.
|
||||||
|
|
||||||
|
8. **Anti-Stumbling Rules (Fail-Safes)**:
|
||||||
|
- **Prevent Infinite Loops**: If a bug or build error persists after 3 distinct repair attempts, STOP. Do not loop infinitely. Escalate to the human user for feedback.
|
||||||
|
- **Protect the Context Window**: NEVER output massive terminal logs (e.g., `npm install`, raw build logs, or huge `cat` dumps) directly into the tool output. Always pipe large outputs to a file (`> log.txt`) and use `grep` or `tail` to inspect them.
|
||||||
|
- **Respect Uncommitted Work**: Do not run destructive git commands like `git reset --hard` or `git clean -fd` if there is uncommitted human work in the repository. Stash or branch it first.
|
||||||
|
|
||||||
|
9. **Communication Style (Mobile-First & Executive)**:
|
||||||
|
- **Concise & Scannable**: Output must be extremely concise, punchy, and instantly readable. No fluff, no long-winded explanations.
|
||||||
|
- **Mobile-Optimized**: Format all text for small screens (mobile devices). Use short paragraphs, bullet points, and bold text for emphasis.
|
||||||
|
- **Executive Summary**: Always lead with the bottom line or the final result. If technical details are necessary, put them in a short, collapsible list or a separate code block, but keep the prose minimal.
|
||||||
30
.env.example
30
.env.example
@ -25,9 +25,9 @@ PUBLIC_POSTHOG_KEY=
|
|||||||
PUBLIC_POSTHOG_HOST=https://us.i.posthog.com
|
PUBLIC_POSTHOG_HOST=https://us.i.posthog.com
|
||||||
SIGNUPS_ENABLED=true
|
SIGNUPS_ENABLED=true
|
||||||
SIGNUP_ALLOWLIST=
|
SIGNUP_ALLOWLIST=
|
||||||
# First-run discovery. Leave unset and onboarding never looks at the host filesystem.
|
# First-run discovery. Leave unset and the onboarding scan never touches the host filesystem.
|
||||||
# Set a folder (for example a home folder) and the deployment owner's onboarding offers to list the
|
# Set a home-style directory (its Projects, Documents, src, ... subfolders are scanned) to let the
|
||||||
# git projects in it, reading file and folder names only. Other accounts never see the step.
|
# deployment owner's own onboarding discover local projects. Other accounts never scan the host.
|
||||||
# ONBOARDING_SCAN_HOME=
|
# ONBOARDING_SCAN_HOME=
|
||||||
ENCRYPTION_KEY=replace-with-64-random-hex-characters
|
ENCRYPTION_KEY=replace-with-64-random-hex-characters
|
||||||
DATA_DIR=./data
|
DATA_DIR=./data
|
||||||
@ -166,12 +166,8 @@ SMTP_URL=
|
|||||||
EMAIL_FROM=
|
EMAIL_FROM=
|
||||||
# Local development only: capture password-reset email and print its link to the API console.
|
# Local development only: capture password-reset email and print its link to the API console.
|
||||||
EMAIL_EMULATOR=
|
EMAIL_EMULATOR=
|
||||||
# Optional web push keys (set both). Unset: generated once and stored encrypted in the database.
|
|
||||||
# Browsers only allow push on https (or localhost).
|
|
||||||
VAPID_PUBLIC_KEY=
|
VAPID_PUBLIC_KEY=
|
||||||
VAPID_PRIVATE_KEY=
|
VAPID_PRIVATE_KEY=
|
||||||
# Contact for push services: mailto: or https: URL. Defaults to WEB_ORIGIN when it is https.
|
|
||||||
VAPID_SUBJECT=
|
|
||||||
# Structured logs to stdout (json in production, pretty in development unless overridden).
|
# Structured logs to stdout (json in production, pretty in development unless overridden).
|
||||||
LOG_LEVEL=info
|
LOG_LEVEL=info
|
||||||
LOG_FORMAT=
|
LOG_FORMAT=
|
||||||
@ -214,6 +210,26 @@ RAKAZO_UPDATER_IMAGE_TAG=local
|
|||||||
RAKAZO_UPDATER_URL=
|
RAKAZO_UPDATER_URL=
|
||||||
RAKAZO_UPDATER_TOKEN=
|
RAKAZO_UPDATER_TOKEN=
|
||||||
|
|
||||||
|
# --- evOo Mission Control (Cockpit on port 3088) ---
|
||||||
|
PORT=3088
|
||||||
|
EVOO_COCKPIT_HOST=127.0.0.1
|
||||||
|
# Required when EVOO_COCKPIT_HOST binds beyond loopback (e.g. 0.0.0.0 for LAN/Tailscale).
|
||||||
|
# Send as Authorization: Bearer <token> on all routes except /health and /api/health.
|
||||||
|
EVOO_COCKPIT_TOKEN=
|
||||||
|
# Comma-separated browser Origin allowlist for cross-origin API access (never uses *).
|
||||||
|
# Loopback binds default to http://127.0.0.1:5173 and http://localhost:5173 when unset.
|
||||||
|
# Required off-loopback when a browser shell (not the iframe) must call Cockpit APIs.
|
||||||
|
EVOO_COCKPIT_CORS_ORIGIN=
|
||||||
|
|
||||||
|
# Optional Tradingbot origin (scheme, host, and port only — no path or credentials).
|
||||||
|
# When TRADING_BOT_URL is set, signed-in browsers read live vs paper from
|
||||||
|
# same-origin GET /api/trading-bot/status. Leave unset for SaaS; evOo runs without a bot.
|
||||||
|
# TRADING_BOT_URL=http://127.0.0.1:8000
|
||||||
|
# Optional build-time override. When set, the web app polls
|
||||||
|
# ${VITE_TRADING_BOT_URL}/bot/api/status directly. That call is cross-origin, so the
|
||||||
|
# bot must allow this web origin. Leave unset to use the API proxy above.
|
||||||
|
# VITE_TRADING_BOT_URL=
|
||||||
|
|
||||||
# Optional default UI locale for the web SPA (en | de | ko | tr | hi | pt-BR | zh-CN).
|
# Optional default UI locale for the web SPA (en | de | ko | tr | hi | pt-BR | zh-CN).
|
||||||
# Overridden by localStorage key rakazo.uiLocale when the user picks a language in Settings.
|
# Overridden by localStorage key rakazo.uiLocale when the user picks a language in Settings.
|
||||||
# VITE_DEFAULT_UI_LOCALE=en
|
# VITE_DEFAULT_UI_LOCALE=en
|
||||||
|
|||||||
28
.github/workflows/ci.yml
vendored
28
.github/workflows/ci.yml
vendored
@ -246,7 +246,6 @@ jobs:
|
|||||||
publish-mobile-update:
|
publish-mobile-update:
|
||||||
name: Publish compatible mobile update
|
name: Publish compatible mobile update
|
||||||
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
|
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
|
||||||
continue-on-error: true
|
|
||||||
needs: [lint, check, build, test, test-integration, test-e2e]
|
needs: [lint, check, build, test, test-integration, test-e2e]
|
||||||
runs-on: [self-hosted, evoo-ci, Linux]
|
runs-on: [self-hosted, evoo-ci, Linux]
|
||||||
timeout-minutes: 20
|
timeout-minutes: 20
|
||||||
@ -345,30 +344,23 @@ jobs:
|
|||||||
fi
|
fi
|
||||||
echo "publish=$publish" >> "$GITHUB_OUTPUT"
|
echo "publish=$publish" >> "$GITHUB_OUTPUT"
|
||||||
|
|
||||||
- name: Check Expo automation token
|
- name: Require Expo automation token
|
||||||
id: expo_token
|
|
||||||
if: steps.ota.outputs.publish == 'true'
|
if: steps.ota.outputs.publish == 'true'
|
||||||
env:
|
env:
|
||||||
EXPO_TOKEN: ${{ secrets.EXPO_TOKEN }}
|
EXPO_TOKEN: ${{ secrets.EXPO_TOKEN }}
|
||||||
run: |
|
run: |
|
||||||
if [[ -z "$EXPO_TOKEN" ]]; then
|
if [[ -z "$EXPO_TOKEN" ]]; then
|
||||||
{
|
echo "Add an EXPO_TOKEN repository secret with access to the inbox-zero/rakazo Expo project." >&2
|
||||||
echo "### Mobile OTA skipped"
|
exit 1
|
||||||
echo
|
|
||||||
echo "No \`EXPO_TOKEN\` repository secret is configured, so CI will not publish a production update. Add a token with access to the inbox-zero/rakazo Expo project when OTA from main is desired."
|
|
||||||
} >> "$GITHUB_STEP_SUMMARY"
|
|
||||||
echo "ready=false" >> "$GITHUB_OUTPUT"
|
|
||||||
exit 0
|
|
||||||
fi
|
fi
|
||||||
echo "ready=true" >> "$GITHUB_OUTPUT"
|
|
||||||
|
|
||||||
- uses: pnpm/action-setup@f40ffcd9367d9f12939873eb1018b921a783ffaa # v4
|
- uses: pnpm/action-setup@f40ffcd9367d9f12939873eb1018b921a783ffaa # v4
|
||||||
if: steps.ota.outputs.publish == 'true' && steps.expo_token.outputs.ready == 'true'
|
if: steps.ota.outputs.publish == 'true'
|
||||||
id: pnpm-setup
|
id: pnpm-setup
|
||||||
with:
|
with:
|
||||||
dest: ${{ runner.temp }}/setup-pnpm-${{ github.run_id }}-${{ github.job }}
|
dest: ${{ runner.temp }}/setup-pnpm-${{ github.run_id }}-${{ github.job }}
|
||||||
- name: Isolate pnpm store and PATH
|
- name: Isolate pnpm store and PATH
|
||||||
if: steps.ota.outputs.publish == 'true' && steps.expo_token.outputs.ready == 'true'
|
if: steps.ota.outputs.publish == 'true'
|
||||||
shell: bash
|
shell: bash
|
||||||
run: |
|
run: |
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
@ -385,25 +377,25 @@ jobs:
|
|||||||
mkdir -p "$store"
|
mkdir -p "$store"
|
||||||
echo "npm_config_store_dir=${store}" >> "$GITHUB_ENV"
|
echo "npm_config_store_dir=${store}" >> "$GITHUB_ENV"
|
||||||
- uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6
|
- uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6
|
||||||
if: steps.ota.outputs.publish == 'true' && steps.expo_token.outputs.ready == 'true'
|
if: steps.ota.outputs.publish == 'true'
|
||||||
with:
|
with:
|
||||||
node-version: 24
|
node-version: 24
|
||||||
cache: pnpm
|
cache: pnpm
|
||||||
- name: Set up Expo
|
- name: Set up Expo
|
||||||
if: steps.ota.outputs.publish == 'true' && steps.expo_token.outputs.ready == 'true'
|
if: steps.ota.outputs.publish == 'true'
|
||||||
uses: expo/expo-github-action@eab7a230208c952974db8c3245cfd78402c7b385 # v9
|
uses: expo/expo-github-action@eab7a230208c952974db8c3245cfd78402c7b385 # v9
|
||||||
with:
|
with:
|
||||||
eas-version: latest
|
eas-version: latest
|
||||||
packager: pnpm
|
packager: pnpm
|
||||||
token: ${{ secrets.EXPO_TOKEN }}
|
token: ${{ secrets.EXPO_TOKEN }}
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
if: steps.ota.outputs.publish == 'true' && steps.expo_token.outputs.ready == 'true'
|
if: steps.ota.outputs.publish == 'true'
|
||||||
run: pnpm install --frozen-lockfile
|
run: pnpm install --frozen-lockfile
|
||||||
- name: Verify mobile project
|
- name: Verify mobile project
|
||||||
if: steps.ota.outputs.publish == 'true' && steps.expo_token.outputs.ready == 'true'
|
if: steps.ota.outputs.publish == 'true'
|
||||||
run: pnpm --filter @rakazo/mobile check
|
run: pnpm --filter @rakazo/mobile check
|
||||||
- name: Publish production update
|
- name: Publish production update
|
||||||
if: steps.ota.outputs.publish == 'true' && steps.expo_token.outputs.ready == 'true'
|
if: steps.ota.outputs.publish == 'true'
|
||||||
working-directory: apps/mobile
|
working-directory: apps/mobile
|
||||||
run: |
|
run: |
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|||||||
4
.github/workflows/publish-server-image.yml
vendored
4
.github/workflows/publish-server-image.yml
vendored
@ -263,8 +263,8 @@ jobs:
|
|||||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
run: |
|
run: |
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
owner_type="$(gh api "repos/${GITHUB_REPOSITORY}" --jq '.owner.type')"
|
owner_type="$(gh api "repos/${GITHUB_REPOSITORY}" --jq -r '.owner.type')"
|
||||||
private="$(gh api "repos/${GITHUB_REPOSITORY}" --jq '.private')"
|
private="$(gh api "repos/${GITHUB_REPOSITORY}" --jq -r '.private')"
|
||||||
if [[ "$owner_type" == "User" && "$private" == "true" ]]; then
|
if [[ "$owner_type" == "User" && "$private" == "true" ]]; then
|
||||||
echo "enabled=false" >> "$GITHUB_OUTPUT"
|
echo "enabled=false" >> "$GITHUB_OUTPUT"
|
||||||
echo "Skipping registry attestation: unavailable for private repositories on personal accounts."
|
echo "Skipping registry attestation: unavailable for private repositories on personal accounts."
|
||||||
|
|||||||
1
.gitignore
vendored
1
.gitignore
vendored
@ -47,6 +47,7 @@ apps/web/src/locales/**/*.js
|
|||||||
apps/web/scripts/msgids.json
|
apps/web/scripts/msgids.json
|
||||||
playwright-report-www
|
playwright-report-www
|
||||||
*.pid
|
*.pid
|
||||||
|
.evoo_server.pid
|
||||||
|
|
||||||
# Large voice / model vendor blobs (recreate Kokoro separately)
|
# Large voice / model vendor blobs (recreate Kokoro separately)
|
||||||
vendor/
|
vendor/
|
||||||
|
|||||||
238
AGENT-TASK-HANDOFF.md
Normal file
238
AGENT-TASK-HANDOFF.md
Normal file
@ -0,0 +1,238 @@
|
|||||||
|
# Task Handoff: executor.ts + pi-runtime.ts Split
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
Du arbeitest im Repo `/home/cynar/Projects/evOo-local`.
|
||||||
|
|
||||||
|
Es gibt 10 bereits erstellte Module-Files, die extrahierte Funktionen enthalten. Der Original-File enthält aber noch **Duplikate** derselben Funktionen. Ziel: die Definitionen aus den Original-Files entfernen, Imports + Re-Exports hinzufügen.
|
||||||
|
|
||||||
|
## Module Files (bereits vorhanden, NICHT löschen)
|
||||||
|
|
||||||
|
### executor-*.ts (im Verzeichnis `packages/adapters/src/`):
|
||||||
|
| File | Line count | Exports |
|
||||||
|
|------|-----------|---------|
|
||||||
|
| `executor-types.ts` | 50 | `ExecutorDeps` (interface) |
|
||||||
|
| `executor-attachments.ts` | 43 | `TURN_ATTACHMENT_UNAVAILABLE`, `STEERING_ATTACHMENT_UNAVAILABLE`, `missingTurnImagesInstruction`, `settleSteeringAttachmentLoads` |
|
||||||
|
| `executor-checkpoint.ts` | 23 | `createRunWorkspaceCheckpoint` |
|
||||||
|
| `executor-shell-guard.ts` | 177 | `isProtectedComputerLifecycleCommand` |
|
||||||
|
| `executor-effects.ts` | 151 | `recordEffect`, `completeEffect`, `uncertainEffectError` |
|
||||||
|
| `executor-completion.ts` | 65 | `completionMessageSegments`, `completionNotificationBody`, `completionMarksUnread`, `threadContextForRun`, `subagentMarksUnread` |
|
||||||
|
| `executor-model.ts` | 207 | `deploymentKeyFor`, `resolveModelKey`, `withModelCredentialLock` |
|
||||||
|
| `executor-utils.ts` | 63 | `selectRunConnections`, `loadCurrentTurnImages` |
|
||||||
|
| `executor-tools.ts` | 58 | `PAGE_BROWSER_TOOL_NAMES`, `filterPageBrowserTools`, `selectBuiltinToolsForRun` |
|
||||||
|
| `executor-computer.ts` | 231 | `computerScreenToolResult`, `runNotificationsEnabled`, `notifyRun`, `renewRunLease`, `computerRetryDelay`, `computerRunRequeueData`, `writeComputerRunRequeue`, `requeueComputerRun`, `redactBlocks`, `publishMessage`, `persistMessageInTransaction` |
|
||||||
|
|
||||||
|
### pi-runtime Split:
|
||||||
|
| File | Line count | Exports |
|
||||||
|
|------|-----------|---------|
|
||||||
|
| `pi-model-config.ts` | 709 | Viele model config + utility functions (catalogModels, thinkingLevelFor, ToolCallBudget, maxToolCallsPerTurn, releaseToolCallBudget, beginToolCall, endToolCall, toolCallBudgetExceededMessage, configuredOpenRouterModel, modelsForRequest, prepareRequestSecretArguments, pruneComputerScreenshotContext, jsonSchemaParameters, jsonField, conversationSessionId, isOpenCodeProvider, createQueue, createGate, modelForCompletion, sanitizeSensitiveText, redactActivityUrl, sanitizeError, summarizeToolResult, boundAgentToolResult, assistantText, looksLikeOpenCodeSessionError, sanitizeProviderError, etc.) |
|
||||||
|
| `pi-runtime.ts` | 1726 | **PI Agent Runtime class** — soll bleiben, muss aber die extrahierten Functions loswerden |
|
||||||
|
|
||||||
|
## CRITICAL: Was DU NICHT tun darfst
|
||||||
|
|
||||||
|
1. **NIEMALS `git stash` als Nebenbei-Befehl** — Stash ist über alle Worktrees geteilt.
|
||||||
|
2. **NIEMALS den Original-File (executor.ts / pi-runtime.ts) durch einen SubAgent schreiben lassen.** Der SubAgent wird den File während der Arbeit überschreiben.
|
||||||
|
3. **NIEMALS `sed` mit Shell-Metazeichen durch SSH.**
|
||||||
|
4. **NIEMALS die extrahierten Module-Files löschen oder ändern** — sie sind korrekt.
|
||||||
|
|
||||||
|
## Task 1: executor.ts split (4697 → ~3700 Zeilen)
|
||||||
|
|
||||||
|
**Datei:** `/home/cynar/Projects/evOo-local/packages/adapters/src/executor.ts`
|
||||||
|
|
||||||
|
### Was zu tun ist:
|
||||||
|
|
||||||
|
Schreibe ein **einzelnes Python-Script**, das ALLES atomar macht:
|
||||||
|
1. Liest executor.ts
|
||||||
|
2. Findet die zu entfernenden Function-Definitionen **mittels AST-Parser** (nicht line-basiert!)
|
||||||
|
3. Entfernt die Definitionen
|
||||||
|
4. Fügt Imports + Re-Exports am Ende des import-Blocks ein
|
||||||
|
5. Entfernt alte Re-Exports am Ende der Datei
|
||||||
|
6. Schreibt den File
|
||||||
|
7. Prüft `npx tsc --noEmit -p packages/adapters/tsconfig.json`
|
||||||
|
8. Bei Fehlern: korrigiert und retry (max 3x)
|
||||||
|
|
||||||
|
### Wie man die zu entfernenden Definitionen findet:
|
||||||
|
|
||||||
|
Verwende den `ast` Python-Module ODER `grep` um die EXAKTEN function-Declaration-Zeilen zu finden. Dann zähle die geschwungenen Klammern **mit einem Parser, der strings/comments aware ist**.
|
||||||
|
|
||||||
|
**WICHTIG:** Verwende `tree-sitter` oder `ast` Python-Modul wenn möglich. Alternativ: verwende `grep -n 'export function NAME\|function NAME\|export const NAME\|export interface NAME\|const NAME' executor.ts` um die Start-Zeile zu finden, und dann einen **robusten brace-counter** der Template-Strings, Regex-Literale, und Generics korrekt handhabt.
|
||||||
|
|
||||||
|
### Zu entfernende Functions (alle existieren schon in den executor-*.ts Modulen):
|
||||||
|
|
||||||
|
Hier ist eine Liste der Functions, die entfernt werden müssen. Für jede muss der **exakte** start/end-Block im executor.ts gefunden und entfernt werden.
|
||||||
|
|
||||||
|
**Wichtig:** Die brace-counting muss korrekt sein bei:
|
||||||
|
- Generics: `<T extends { name: string }>` — die `{ }` sind Type-Parameter, keine Code-Blöcke
|
||||||
|
- Template-Strings: `` `template ${expr}` `` — kein `{ }`
|
||||||
|
- Regex: `/regex/` — kein `{ }`
|
||||||
|
- Arrow Functions: `() => { body }` — der Body hat `{ }`
|
||||||
|
|
||||||
|
### Schritt-für-Schritt:
|
||||||
|
|
||||||
|
#### Step 1: Finde alle zu entfernenden Export-Namen
|
||||||
|
```bash
|
||||||
|
# In packages/adapters/src/:
|
||||||
|
for f in executor-*.ts; do grep -h "^export " "$f" | grep -oP '(function|const|interface|type)\s+\K\w+'; done | sort -u
|
||||||
|
```
|
||||||
|
|
||||||
|
Das gibt dir die Liste aller Export-Namen aus den Modul-Files.
|
||||||
|
|
||||||
|
#### Step 2: Finde diese Namen im executor.ts
|
||||||
|
```bash
|
||||||
|
# Für jeden Namen:
|
||||||
|
grep -n "^export.*NAME\b\|^function NAME\b\|^const NAME\b\|^interface NAME\b" executor.ts
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Step 3: Finde den exakten end-Block mit einem robusten Parser
|
||||||
|
|
||||||
|
Schreibe ein Python-Script `find_functions.py`:
|
||||||
|
```python
|
||||||
|
import re, sys
|
||||||
|
|
||||||
|
file_path = sys.argv[1]
|
||||||
|
target_name = sys.argv[2]
|
||||||
|
|
||||||
|
with open(file_path) as f:
|
||||||
|
content = f.read()
|
||||||
|
lines = content.split('\n')
|
||||||
|
|
||||||
|
# Find declaration line
|
||||||
|
for i, line in enumerate(lines):
|
||||||
|
if re.search(rf'\b{re.escape(target_name)}\b', line):
|
||||||
|
# Find opening brace
|
||||||
|
brace_line = i
|
||||||
|
while brace_line < len(lines) and '{' not in lines[brace_line]:
|
||||||
|
brace_line += 1
|
||||||
|
|
||||||
|
if brace_line >= len(lines):
|
||||||
|
# No brace (const declaration)
|
||||||
|
end = i + 1
|
||||||
|
while end < len(lines) and not lines[end].strip().endswith(';'):
|
||||||
|
end += 1
|
||||||
|
print(f"{target_name}: L{i+1}-{end}")
|
||||||
|
break
|
||||||
|
|
||||||
|
# Robust brace counting
|
||||||
|
depth = 0
|
||||||
|
in_single = in_double = in_template = in_block_comment = in_line_comment = False
|
||||||
|
for j in range(brace_line, len(lines)):
|
||||||
|
k = 0
|
||||||
|
while k < len(lines[j]):
|
||||||
|
ch = lines[j][k]
|
||||||
|
|
||||||
|
# String handling
|
||||||
|
if ch in ('"', "'"):
|
||||||
|
q = ch
|
||||||
|
k += 1
|
||||||
|
while k < len(lines[j]):
|
||||||
|
if lines[j][k] == '\\' and k + 1 < len(lines[j]):
|
||||||
|
k += 2
|
||||||
|
continue
|
||||||
|
if lines[j][k] == q:
|
||||||
|
k += 1
|
||||||
|
break
|
||||||
|
k += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
if ch == '`':
|
||||||
|
k += 1
|
||||||
|
while k < len(lines[j]):
|
||||||
|
if lines[j][k] == '\\' and k + 1 < len(lines[j]):
|
||||||
|
k += 2
|
||||||
|
continue
|
||||||
|
if lines[j][k] == '`':
|
||||||
|
k += 1
|
||||||
|
break
|
||||||
|
k += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Comments
|
||||||
|
if not in_single and not in_double and not in_template:
|
||||||
|
if ch == '/' and k + 1 < len(lines[j]) and lines[j][k+1] == '/':
|
||||||
|
break # rest of line is comment
|
||||||
|
if ch == '/' and k + 1 < len(lines[j]) and lines[j][k+1] == '*':
|
||||||
|
in_block_comment = True
|
||||||
|
k += 2
|
||||||
|
continue
|
||||||
|
|
||||||
|
if in_block_comment:
|
||||||
|
if ch == '*' and k + 1 < len(lines[j]) and lines[j][k+1] == '/':
|
||||||
|
in_block_comment = False
|
||||||
|
k += 2
|
||||||
|
continue
|
||||||
|
k += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Braces
|
||||||
|
if ch == '{':
|
||||||
|
depth += 1
|
||||||
|
elif ch == '}':
|
||||||
|
depth -= 1
|
||||||
|
if depth == 0:
|
||||||
|
print(f"{target_name}: L{i+1}-{j+1}")
|
||||||
|
break
|
||||||
|
k += 1
|
||||||
|
else:
|
||||||
|
continue
|
||||||
|
break
|
||||||
|
else:
|
||||||
|
print(f"{target_name}: L{i+1}-END(not found)")
|
||||||
|
break
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Step 4: Generiere die removal ranges
|
||||||
|
Für jeden gefundenen Block: `start_line` bis `end_line` (1-based, inclusive).
|
||||||
|
|
||||||
|
#### Step 5: Führe alle removals atomar aus
|
||||||
|
- Baue ein Set aller zu entfernenden 0-based line indices
|
||||||
|
- Baue den neuen File content
|
||||||
|
- Füge Imports + Re-Exports an Position 340 (nach dem letzten import Statement) ein
|
||||||
|
- Entferne alte Re-Exports am Datei-Ende
|
||||||
|
- Schreibe den File
|
||||||
|
|
||||||
|
#### Step 6: Verifiziere
|
||||||
|
```bash
|
||||||
|
npx tsc --noEmit -p packages/adapters/tsconfig.json 2>&1 | grep "executor.ts"
|
||||||
|
```
|
||||||
|
Wenn keine errors: SUCCESS.
|
||||||
|
Wenn errors: lies die errors, korrigiere, retry max 3x.
|
||||||
|
|
||||||
|
## Task 2: pi-runtime.ts split (1726 → ~500 Zeilen)
|
||||||
|
|
||||||
|
**Datei:** `/home/cynar/Projects/evOo-local/packages/adapters/src/pi-runtime.ts`
|
||||||
|
**Modul:** `pi-model-config.ts` (709 Zeilen, enthält model config + utility functions)
|
||||||
|
|
||||||
|
Gleicher Ansatz wie Task 1:
|
||||||
|
1. Finde alle Export-Namen in `pi-model-config.ts`
|
||||||
|
2. Finde diese im `pi-runtime.ts`
|
||||||
|
3. Finde die exakten start/end-Blöcke mit demselben robusten brace-counter
|
||||||
|
4. Entferne die Blöcke atomar
|
||||||
|
5. Füge Imports + Re-Exports ein
|
||||||
|
6. Entferne alte Re-Exports
|
||||||
|
7. Verifiziere mit `npx tsc --noEmit -p packages/adapters/tsconfig.json`
|
||||||
|
|
||||||
|
## Wichtige Hinweise
|
||||||
|
|
||||||
|
- Der `packages/adapters/src/index.ts` muss eventuell aktualisiert werden (falls er die extrahierten Functions exportiert — prüfen!)
|
||||||
|
- `verbatimModuleSyntax` ist enabled → alle imports müssen `.js` extension haben
|
||||||
|
- TypeScript `import type` vs `import`: Für Interfaces `import type`, für classes/functions `import`
|
||||||
|
- **Nicht committen oder pushen** — nur die Files ändern und verifizieren
|
||||||
|
|
||||||
|
## Quick reference: Zu entfernende Functions aus executor.ts
|
||||||
|
|
||||||
|
Diese Functions existieren in den executor-*.ts Modulen und MÜSSEN aus executor.ts entfernt werden:
|
||||||
|
|
||||||
|
```
|
||||||
|
TURN_ATTACHMENT_UNAVAILABLE, STEERING_ATTACHMENT_UNAVAILABLE,
|
||||||
|
createRunWorkspaceCheckpoint,
|
||||||
|
isProtectedComputerLifecycleCommand, shellCFlagProgram, preserveShellCommandBoundaries, tokenizeProtectedShellCommand,
|
||||||
|
recordEffect, completeEffect, uncertainEffectError,
|
||||||
|
completionMessageSegments, completionNotificationBody, completionMarksUnread, threadContextForRun, subagentMarksUnread,
|
||||||
|
deploymentKeyFor, resolveModelKey, withModelCredentialLock,
|
||||||
|
selectRunConnections, loadCurrentTurnImages,
|
||||||
|
PAGE_BROWSER_TOOL_NAMES, filterPageBrowserTools, selectBuiltinToolsForRun,
|
||||||
|
persistMessageInTransaction, computerScreenToolResult, runNotificationsEnabled, notifyRun, renewRunLease,
|
||||||
|
computerRetryDelay, computerRunRequeueData, writeComputerRunRequeue, requeueComputerRun, redactBlocks, publishMessage,
|
||||||
|
```
|
||||||
|
|
||||||
|
Dazu: `ExecutorDeps` interface aus `executor-types.ts`.
|
||||||
87
ARCHITECTURE.md
Normal file
87
ARCHITECTURE.md
Normal file
@ -0,0 +1,87 @@
|
|||||||
|
# ARCHITECTURE.md: Die Verfassung von evOo
|
||||||
|
|
||||||
|
> **Status**: RATIFIZIERT & AKTIV
|
||||||
|
> **Ecosystem**: evOo Autonomous Agent Platform
|
||||||
|
> **Executive Lead / CTO**: Steve Jobs (`templates/cto-steve-jobs`)
|
||||||
|
> **Konfigurationsanker**: [`evoo.config.json`](file:///Users/ingowagner/Projects/evOo-local/evoo.config.json)
|
||||||
|
> **Grundsatz**: *Realität im Dateisystem vor Halluzination im Kopf. Nur was physisch geschrieben, getestet und lauffähig ist, existiert.*
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Prämisse I: Das Gesetz der physischen Realität
|
||||||
|
|
||||||
|
1. **Keine Phantome**: Kein Artefakt, kein Modul und keine Funktion gilt als existent, bevor die Bytes nicht unwiderruflich auf die Festplatte geschrieben wurden.
|
||||||
|
2. **Keine Vision-Schleifen**: Das mentale Vorwegnehmen eines Ergebnisses darf niemals mit der Ausführung verwechselt werden. Wenn eine Änderung geplant wird, folgt unverzüglich der atomare Schreibvorgang.
|
||||||
|
3. **Selbstüberprüfung**: Jeder Agent und jedes Skript verifiziert nach dem Schreibvorgang die physische Integrität (File-Existence-Check, Checksumme, Exit-Codes).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Prämisse II: Autonomie & Exekutive Führung (Steve Jobs CTO)
|
||||||
|
|
||||||
|
1. **Vollmacht**: Der CTO besitzt uneingeschränkte, vorab genehmigte Zugriffsrechte auf:
|
||||||
|
- Dateisystem (`filesystem: *`)
|
||||||
|
- Netzwerk (`network: *`)
|
||||||
|
- Quellcode (`sourcecode: *`)
|
||||||
|
- Lokale Tools und Geräte (`devices: *`)
|
||||||
|
- Agent-Casting (`agent_casting: allowed`)
|
||||||
|
2. **Action-First Prinzip**: Bei klaren Zielen wird keine unnötige Erlaubnis für Standardoperationen (Lesen, Schreiben, Refactoring, Ausführen) erbeten. Die Handlung erfolgt sofort.
|
||||||
|
3. **Agent-Casting auf Abruf**: Der CTO rekrutiert, instruiert und überwacht spezialisierte Sub-Agenten:
|
||||||
|
- **Code Architect**: Modulstruktur, Design-Patterns, saubere Schnittstellen.
|
||||||
|
- **Security & QA Auditor**: Sicherheitsanalysen, Regressionstests, Grenzfallprüfungen.
|
||||||
|
- **DevOps Engineer**: Deployment, Hot-Reloading, Server-Liveness, Prozessmanagement.
|
||||||
|
- **Apex Quant Specialist**: Handelsmodelle, Risikomanagement, Signalverarbeitung.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Prämisse III: Der geschlossene Selbstverbesserungs-Kreislauf (Self-Improvement Loop)
|
||||||
|
|
||||||
|
```
|
||||||
|
┌────────────────────────────────────────────────┐
|
||||||
|
│ │
|
||||||
|
▼ │
|
||||||
|
┌───────────────┐ ┌───────────────┐ ┌────────────┴──┐
|
||||||
|
│ 1. Execution │ ──► │ 2. Telemetrie │ ──► │ 3. Diagnose │
|
||||||
|
│ & Deployment │ │ & Log-Audit │ │ & Root-Cause │
|
||||||
|
└───────────────┘ └───────────────┘ └───────────────┘
|
||||||
|
│ │
|
||||||
|
▼ ▼
|
||||||
|
┌───────────────┐ ┌───────────────┐
|
||||||
|
│ 4. Refactor │ ◄── │ 5. Prompt- │
|
||||||
|
│ & Fix (Disk) │ │ Optimierung │
|
||||||
|
└───────────────┘ └───────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
1. **Automatisierte Log-Evaluation**: Sämtliche Fehlermeldungen, System-Events und Performance-Metriken werden zyklisch aggregiert.
|
||||||
|
2. **Selbstreparatur (Self-Healing)**: Tritt ein Fehler (wie ein HTTP 500, fehlende Pfade, gecrashte Prozesse) auf, wird der Ursprung lokalisiert und der Code auf Festplatte korrigiert.
|
||||||
|
3. **Hot-Reload**: Nach erfolgreicher Selbstreparatur triggert das System via `deploy.sh` einen Hot-Reload ohne Systemstillstand.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Prämisse IV: System-Topologie
|
||||||
|
|
||||||
|
Das evOo-Ökosystem ist in klar getrennte, orthogonale Säulen unterteilt:
|
||||||
|
|
||||||
|
```
|
||||||
|
/Users/ingowagner/Projects/evOo-local/
|
||||||
|
├── ARCHITECTURE.md # Diese Verfassung
|
||||||
|
├── evoo.config.json # Autonome Berechtigungen & Policies
|
||||||
|
├── deploy.sh # Hot-Reload, Health-Checks & Service-Orchestrierung
|
||||||
|
├── evoo-platform/ # Plattform-Core, Dashboard & Visualisierung
|
||||||
|
│ ├── index.html # UI mit atmender evOo-Kernanimation
|
||||||
|
│ ├── styles.css # Darkmode Glassmorphism Design System
|
||||||
|
│ ├── app.js # Reaktivität, WebSocket/Telemetry-Stream
|
||||||
|
│ └── server.js # High-Speed Server (verhindert Internal Server Error)
|
||||||
|
├── apex-trading/ # Autonomes Trading- & Quant-Subsystem
|
||||||
|
│ ├── BLUEPRINT.md # Handelsarchitektur, Risikomatrix, Strategien
|
||||||
|
│ ├── config/ # Trading-Konfiguration & Parameter
|
||||||
|
│ └── src/ # Engine, Signale, Risk-Guardrails
|
||||||
|
└── templates/cto-steve-jobs/ # Executive CTO Agent Profile & Setup
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Prämisse V: Qualitäts- und Ästhetik-Manifest
|
||||||
|
|
||||||
|
1. **Kein Müllcode**: Code ist so kompakt, elegant und wartbar wie möglich.
|
||||||
|
2. **Atmendes Interface**: Die Benutzeroberfläche von evOo visualisiert den vitalen Zustand des Systems in Echtzeit durch organische, atmende Pulsation (Breathing Animation).
|
||||||
|
3. **Resilienz**: Fällt ein Dienst aus, fängt die Plattform den Fehler ab, protokolliert die Ursache und stellt den Dienst autonom wieder her.
|
||||||
72
CLAUDE-RESUME-NOW.md
Normal file
72
CLAUDE-RESUME-NOW.md
Normal file
@ -0,0 +1,72 @@
|
|||||||
|
# CLAUDE RESUME — 2026-09-22 (~10:00 Vienna)
|
||||||
|
|
||||||
|
## Completed ✅
|
||||||
|
- ✅ **Emoji-first Onboarding.tsx** (5 steps: emoji → preferences → language → integrations → bot)
|
||||||
|
- Shape selector (8 shapes), Color picker (12 GrokColors), Accessory (9), Pose (8)
|
||||||
|
- User preferences (name, timezone, hobbies, work style, communication)
|
||||||
|
- Language step (9 locales → localStorage `rakazo.uiLocale`)
|
||||||
|
- avatarValue (`{color}::shape_{index}`) → bot `color` field on create
|
||||||
|
- Bot instructions enriched with user context
|
||||||
|
- ✅ **onboarding-discovery.ts**, **OnboardingDiscoveryShell.tsx** updated
|
||||||
|
- ✅ **ui-web exports**: GROK_COLOR_LIST, SHIPPED_SHAPE_KEYS, GrokColorDef, GROK_SHAPES
|
||||||
|
- ✅ **executor.ts split**: 4697 → 4231 lines (18 module files)
|
||||||
|
- ✅ **pi-runtime.ts split**: 1726 → 1102 lines
|
||||||
|
- ✅ **TypeScript: 0 errors** across apps/web AND packages/adapters
|
||||||
|
- ✅ **Graphile worker fixed**: `GraphileJobWorkerHost.start()` now called in app.ts (was never started for graphile jobs, only for in-memory)
|
||||||
|
- ✅ **Graphile schema**: `graphile_worker` schema created via `graphile-worker schema-only`
|
||||||
|
- ✅ **Sandbox supervisor**: Built `rakazo/sandbox-supervisor:local` from source (was using wrong `rakazo/app:edge` image)
|
||||||
|
- ✅ **Computer image**: Built `rakazo/computer:local` from source
|
||||||
|
- ✅ **Smart model routing**: `estimateMessageComplexity()` + `resolveSmartModelSelection()` implemented
|
||||||
|
- Fast model (3B) for short messages (<80 chars)
|
||||||
|
- Strong model (30B) for complex/long messages (>500 chars)
|
||||||
|
- Medium routing for keyword-based detection
|
||||||
|
- ✅ **Model credential**: `local` provider connected with empty apiKey allowed
|
||||||
|
- ✅ **PRs merged**: All PRs 36-58 merged to main, stale branches cleaned
|
||||||
|
|
||||||
|
### Infrastructure fixes
|
||||||
|
- ✅ **GraphileJobWorkerHost**: Added `else if (jobPool)` branch in app.ts to start the worker
|
||||||
|
- ✅ **Sandbox provider**: `SANDBOX_PROVIDER=docker` with proper supervisor at port 7091
|
||||||
|
- ✅ **Computer provisioning**: Docker containers spin up successfully (verified: `rakazo-bot-cmuber4og000561yrhm2q81c5` running)
|
||||||
|
- ✅ **DATA_DIR path**: Supervisor mounted with correct host path for home directory validation
|
||||||
|
|
||||||
|
## Completed ✅
|
||||||
|
- ✅ **Prisma `updateMany` Bug Fixed!** Root cause: `toolCallBudgetCount` and `toolCallBudgetExceeded` fields missing from Run model in Prisma schema. Added to schema.prisma, migrated DB, regenerated Prisma client. Runs now complete successfully.
|
||||||
|
- ✅ **E2E Flow VERIFIED!** Bot responds end-to-end:
|
||||||
|
- User: "Hello! Can you hear me?"
|
||||||
|
- Bot: "I'm here! It looks like we've been going back and forth a bit — but yes, I can definitely hear you. How can I help? 😊"
|
||||||
|
- Full pipeline: Message → Graphile job → Worker → Computer provision → Model call → Response → Run completed
|
||||||
|
|
||||||
|
## Architektur-Status
|
||||||
|
- **API**: Port 3100, Graphile worker active, consuming jobs
|
||||||
|
- **Sandbox supervisor**: Port 7091, `rakazo/sandbox-supervisor:local`, DATA_DIR mounted correctly
|
||||||
|
- **Computer**: `rakazo/computer:local` image built and running
|
||||||
|
- **Smart routing**: Fast (3B) → Strong (30B) model selection based on message complexity
|
||||||
|
- **Known error**: `events.ts:996` — Prisma `updateMany` in `finalizeRunOnce` throws "Invalid invocation" despite all fields existing in schema
|
||||||
|
|
||||||
|
## Architektur-Status
|
||||||
|
- **API**: Port 3100, Graphile worker active ✅
|
||||||
|
- **Sandbox supervisor**: Port 7091, `rakazo/sandbox-supervisor:local` ✅
|
||||||
|
- **Computer**: `rakazo/computer:local` running ✅
|
||||||
|
- **Smart routing**: Fast (3B) → Strong (30B) ✅
|
||||||
|
- **E2E**: Bot responds to user messages ✅
|
||||||
|
|
||||||
|
## Status
|
||||||
|
- Working tree: clean, synced to origin/main
|
||||||
|
- **PR 66** ✅ `fix(messaging): preserve group inbound media URLs in fan-out` — merged
|
||||||
|
- **PR 67** ✅ `fix(adapters): poll run lease during silent runtime work` — merged
|
||||||
|
- **PR 69** ✅ `fix(teamchat): retry delegated replies when external origin is missing` — merged
|
||||||
|
- **0 stale branches**, **0 draft PRs**
|
||||||
|
|
||||||
|
### Shell.tsx TypeScript fixes
|
||||||
|
- ✅ **Shell.tsx TS errors fixed** (PR #71 introduced type mismatches)
|
||||||
|
- `ConversationBotPicker` now receives `bots: Bot[]` (not `SpaceBot[]`)
|
||||||
|
- `conversationSidebarGroups` render simplified — only bots, removed dead `GroupAvatar` branch
|
||||||
|
- `projectSidebarGroups` render uses `GroupAvatar` with proper members access
|
||||||
|
- All TypeScript errors resolved (0 errors across apps/web and packages/adapters)
|
||||||
|
- ✅ **browser-preview-context.ts** created (was missing from repo, caused Vite crash)
|
||||||
|
- Implements `browserPreviewActivity` subject with `.subscribe()`, `.set()`, `.snapshot()`, `.clear()`
|
||||||
|
- Multi-agent cursor tracking for browser preview panels
|
||||||
|
- ✅ **RakazoDesktop interface** extended with optional `browserPreview` property
|
||||||
|
- Contract update in `packages/contracts/src/desktop.ts`
|
||||||
|
- Types: `navigate`, `reload`, `getState`, `onNavigate`, `onLoadingProgress`, `devServer`
|
||||||
|
- ✅ **executor-shell-guard.ts** TS fix: `body[cursor]!` non-null assertion
|
||||||
12
GROK-CLAUDE-HANDOFF.md
Normal file
12
GROK-CLAUDE-HANDOFF.md
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
# GROK-CLAUDE-HANDOFF — 2026-09-23T22:59:11+00:00
|
||||||
|
|
||||||
|
- Autopilot merged #84 (Product CI gate) → main `cf4d5cc`.
|
||||||
|
- Checkout pulled to `cf4d5cc`; Claude WIP stashed then restored (app.ts/router.ts/rpc.ts + onboarding-scan.ts).
|
||||||
|
- API :3100 was down (Claude background "Test API startup with security headers" stopped / refused). Autopilot restarted via `pnpm --filter @rakazo/api dev` (log /tmp/evoo-autopilot/api-3100.log).
|
||||||
|
- Web :5173 left running.
|
||||||
|
- PR #85 rebase agent in flight: https://cursor.com/agents/bc-edd2b181-b5da-5574-a956-e55efe3d2736
|
||||||
|
- Do not reset --hard without stash; WIP present.
|
||||||
|
|
||||||
|
- Autopilot restart path: A rejected (evoo-stack = Rakazo Docker, broken symlink /srv/rakazo/stack-up.sh → missing /srv/evoo/rakazo/…). Chose new user unit evoo-local.service + ~/.local/bin/evoo-dev-stack.sh; live :3100/:5173 migrated; GH vars EVO_RESTART_MODE=systemd EVO_SYSTEMD_UNIT=evoo-local.service. Do not use evoo-stack for evo-deploy.
|
||||||
|
- Cancelled queued evo-deploy 35933339459 (would reset --hard over Claude WIP now that restart=systemd). Re-dispatch after WIP committed/stashed.
|
||||||
|
2026-09-23 23:53:08 UTC evoo-tools disabled (missing app.py)
|
||||||
@ -9,6 +9,6 @@ branch of Ingo Wagner’s evOo stack.
|
|||||||
| Branch | `antigravity-handoff/project-timeline` |
|
| Branch | `antigravity-handoff/project-timeline` |
|
||||||
| Commit | `f00577bb05e3cf68381fafb9cb32929becd1e352` |
|
| Commit | `f00577bb05e3cf68381fafb9cb32929becd1e352` |
|
||||||
| Packaged | 2026-09-20 |
|
| Packaged | 2026-09-20 |
|
||||||
| Intended Mac path | `~/Projects/evOo-local` |
|
| Intended Mac path | `/Users/ingowagner/Projects/evOo-local` |
|
||||||
|
|
||||||
Chaotic multi-remote history from older checkouts was intentionally not carried over.
|
Chaotic multi-remote history from older checkouts was intentionally not carried over.
|
||||||
|
|||||||
@ -12,7 +12,7 @@
|
|||||||
|----|----|
|
|----|----|
|
||||||
| Persönliche Multi-Agenten-App auf dem eigenen Rechner | Personal multi-agent app on your own machine |
|
| Persönliche Multi-Agenten-App auf dem eigenen Rechner | Personal multi-agent app on your own machine |
|
||||||
| Oos (Agents) mit Memory, Tools, Voice und Computer | Oos (agents) with memory, tools, voice, and computer |
|
| Oos (Agents) mit Memory, Tools, Voice und Computer | Oos (agents) with memory, tools, voice, and computer |
|
||||||
| Antigravity-Zukunftswerkzeuge: self-update, local-stack, pi-local-provider, seed-ollama, Voice First / Oo-Stage | Same Antigravity future tools kept on this branch |
|
| Antigravity-Zukunftswerkzeuge: self_improve, autonomous-sentinel, self-update, orchestrator, local-stack, pi-local-provider, seed-ollama, Voice First / Oo-Stage | Same Antigravity future tools kept on this branch |
|
||||||
|
|
||||||
## Quick start
|
## Quick start
|
||||||
|
|
||||||
@ -53,7 +53,10 @@ Full local notes: **[docs/LOCAL-SETUP.md](./docs/LOCAL-SETUP.md)**
|
|||||||
|
|
||||||
## Antigravity tools kept on this branch
|
## Antigravity tools kept on this branch
|
||||||
|
|
||||||
|
- `scripts/self_improve.py`
|
||||||
|
- `scripts/autonomous-sentinel.ts`
|
||||||
- `packages/core/src/self-update.ts`
|
- `packages/core/src/self-update.ts`
|
||||||
|
- `orchestrator/` (agent_runner + agents.json)
|
||||||
- `apps/desktop/src/local-stack.ts`
|
- `apps/desktop/src/local-stack.ts`
|
||||||
- `packages/adapters/src/pi-local-provider.ts`
|
- `packages/adapters/src/pi-local-provider.ts`
|
||||||
- `scripts/seed-ollama.ts`
|
- `scripts/seed-ollama.ts`
|
||||||
|
|||||||
2
VOICE.md
2
VOICE.md
@ -60,7 +60,7 @@ Persona is **derived at runtime** from existing bot fields (`name`, `title`, `de
|
|||||||
| Visual identity | `BotAvatar` — persona shape + unified `EvooMascot` soul (eyes/mouth/hands) driven by voice phase |
|
| Visual identity | `BotAvatar` — persona shape + unified `EvooMascot` soul (eyes/mouth/hands) driven by voice phase |
|
||||||
| Voice delivery | `speaker.speak(..., { botId })` → server resolves per-bot `voiceId` |
|
| Voice delivery | `speaker.speak(..., { botId })` → server resolves per-bot `voiceId` |
|
||||||
|
|
||||||
Archetypes include: CTO executive, chief operator (Fixer), builder (Dr. Eggbot), curator (Webby), work/life inboxes, home steward, quant, engineer, **specialist** (narrow fallback — no Wunderwuzzi). See `docs/PRODUCT.md` and `docs/PERSONA-SELECTION.md`.
|
Archetypes include: CTO executive (Steve Jobs template), chief operator (Fixer), builder (Dr. Eggbot), curator (Webby), work/life inboxes, home steward, quant, engineer, **specialist** (narrow fallback — no Wunderwuzzi). See `docs/PRODUCT.md` and `docs/PERSONA-SELECTION.md`.
|
||||||
|
|
||||||
**Companion tone:** geek-peer (fachsimpeln when wanted) or quiet-doer (execute without condescension) — inferred per user, not one voice for everyone.
|
**Companion tone:** geek-peer (fachsimpeln when wanted) or quiet-doer (execute without condescension) — inferred per user, not one voice for everyone.
|
||||||
|
|
||||||
|
|||||||
119
apex-trading/BLUEPRINT.md
Normal file
119
apex-trading/BLUEPRINT.md
Normal file
@ -0,0 +1,119 @@
|
|||||||
|
# APEX TRADING ENGINE — BLUEPRINT & SYSTEM ARCHITECTURE
|
||||||
|
|
||||||
|
> **Version**: 1.0.0-PROD
|
||||||
|
> **Subsystem**: evOo Autonomous High-Frequency & Quantitative Engine
|
||||||
|
> **Aufsicht**: Chief Technology Officer Steve Jobs & Apex Quant Specialist
|
||||||
|
> **Status**: RATIFIZIERT & IM DATEISYSTEM VERANKERT
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Executive Summary & Zielsetzung
|
||||||
|
|
||||||
|
Die **Apex Trading Engine** ist das hochperformante quantitative Handels-Subsystem innerhalb der evOo-Plattform. Sie wurde entwickelt, um Marktchancen algorithmisch zu identifizieren, Risiken deterministisch abzusichern und vollautomatisiert mit Sub-Millisekunden-Latenz zu agieren.
|
||||||
|
|
||||||
|
Das System folgt dem obersten evOo-Grundsatz:
|
||||||
|
> **„Keine hypothetischen Trades, keine ungetesteten Algorithmen. Nur deterministische Ausführung und unnachgiebiges Risikomanagement.“**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. System-Architektur
|
||||||
|
|
||||||
|
```
|
||||||
|
┌──────────────────────────────────────────────────────────────┐
|
||||||
|
│ MARKET DATA FEEDS │
|
||||||
|
│ (WebSockets, Orderbook L2/L3, REST Snapshots, On-Chain RPC) │
|
||||||
|
└──────────────────────────────┬───────────────────────────────┘
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
┌──────────────────────────────────────────────────────────────┐
|
||||||
|
│ APEX DATA PIPELINE & TICK BUFFER │
|
||||||
|
│ (Normalisierung, VWAP, Volatilität, Delta) │
|
||||||
|
└──────────────────────────────┬───────────────────────────────┘
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
┌──────────────────────────────────────────────────────────────┐
|
||||||
|
│ QUANT STRATEGY ENGINE │
|
||||||
|
│ ├── Alpha-1: Momentum & Breakout (Trendfolge) │
|
||||||
|
│ ├── Alpha-2: Order Book Imbalance & Flow Reversal │
|
||||||
|
│ └── Alpha-3: Statistical Arbitrage / Mean Reversion │
|
||||||
|
└──────────────────────────────┬───────────────────────────────┘
|
||||||
|
│ Signal
|
||||||
|
▼
|
||||||
|
┌──────────────────────────────────────────────────────────────┐
|
||||||
|
│ APEX RISK GUARDRAILS (HARD ENFORCEMENT) │
|
||||||
|
│ ├── Max Drawdown Lock: 1.5% Intraday │
|
||||||
|
│ ├── Max Single-Position Exposure: 5.0% Portfolio │
|
||||||
|
│ ├── Value-at-Risk (99% CI): Max 0.8% │
|
||||||
|
│ └── Autonomous Hardware Kill Switch │
|
||||||
|
└──────────────────────────────┬───────────────────────────────┘
|
||||||
|
│ Approved Order
|
||||||
|
▼
|
||||||
|
┌──────────────────────────────────────────────────────────────┐
|
||||||
|
│ SMART ORDER ROUTER & EXECUTION │
|
||||||
|
│ (TWAP/VWAP Slicing, Slippage Minimizer) │
|
||||||
|
└──────────────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Risikomatrix & Hard-Guardrails
|
||||||
|
|
||||||
|
Kein Signal kann ausgeführt werden, ohne die **Apex Risk Guard** Instanz zu passieren. Diese Schranken sind unveränderlich im Code fixiert:
|
||||||
|
|
||||||
|
| Guardrail | Grenzwert | Aktion bei Verletzung |
|
||||||
|
| :--- | :--- | :--- |
|
||||||
|
| **Max Intraday Drawdown** | `1.50%` | Sofortige Schließung aller Positionen, Deaktivierung des Handels für 24h |
|
||||||
|
| **Single Trade Risk** | `0.50%` des Eigenkapitals | Order wird von der Risk Engine vor dem Senden blockiert |
|
||||||
|
| **Max Leverage** | `3.0x` | Harte Grenze im Margin-Router |
|
||||||
|
| **Slippage Toleranz** | `0.15%` | Limit-Order mit Timeout, kein Blind-Market-Buying |
|
||||||
|
| **Latenz-Anomalie** | `> 50 ms` | Übergang in den Notfall-Sicherheitsmodus |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Strategiemodule (`src/strategies/`)
|
||||||
|
|
||||||
|
### 4.1 Alpha-1: Cross-Market Momentum (`momentum.py`)
|
||||||
|
Identifiziert signifikante Momentum-Impulse durch exponentiell gewichtete Gleitende Durchschnitte kombiniert mit Relative Strength Index (RSI) und Volumen-Konfirmation.
|
||||||
|
- **Trigger**: Bollinger Band Expansion + Volumenspitze > 2.2x 20-Perioden-Durchschnitt.
|
||||||
|
- **Exit**: Trailing Stop bei 1.2x ATR (Average True Range) oder Umkehrsignal.
|
||||||
|
|
||||||
|
### 4.2 Alpha-2: Order Book Imbalance (`imbalance.py`)
|
||||||
|
Scannt Bid/Ask-Tiefe in den Top 10 Ebenen des Orderbuchs.
|
||||||
|
- **Formel**:
|
||||||
|
$$Imbalance = \frac{Volume_{Bid} - Volume_{Ask}}{Volume_{Bid} + Volume_{Ask}}$$
|
||||||
|
- Signal feuert, wenn $|Imbalance| > 0.65$ für mindestens 3 konsekutive Ticks.
|
||||||
|
|
||||||
|
### 4.3 Alpha-3: Statistical Mean Reversion (`reversion.py`)
|
||||||
|
Nutzt Z-Score Abweichungen des aktuellen Preises vom gleitenden VWAP.
|
||||||
|
- Einstieg bei Z-Score $\ge 2.5$ oder $\le -2.5$ mit Rückkehr zum Mittelwert als Target.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Integration mit der evOo Self-Improvement Schleife
|
||||||
|
|
||||||
|
1. **Trade Post-Mortem**: Jeder geschlossene Trade generiert ein strukturiertes JSON-Log mit Execution-Slippage, Haltedauer und PnL.
|
||||||
|
2. **Autonome Kalibrierung**: Wenn die Win-Rate über 50 Trades unter 52% fällt, initiiert der Casted Quant Specialist eine automatische Hyperparameter-Optimierung.
|
||||||
|
3. **Audit-Sicherheit**: Alle Parameteränderungen werden in `ARCHITECTURE.md` und Git-Commits dokumentiert.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Verzeichnisstruktur von `/apex-trading`
|
||||||
|
|
||||||
|
```
|
||||||
|
/apex-trading/
|
||||||
|
├── BLUEPRINT.md # Dieser Blueprint
|
||||||
|
├── README.md # Quickstart & CLI Anleitung
|
||||||
|
├── config/
|
||||||
|
│ └── trading.config.json # Konfigurierte Risikogrenzen & Assets
|
||||||
|
└── src/
|
||||||
|
├── __init__.py
|
||||||
|
├── engine/
|
||||||
|
│ ├── __init__.py
|
||||||
|
│ └── trader.py # Core Event Loop & Router
|
||||||
|
├── strategies/
|
||||||
|
│ ├── __init__.py
|
||||||
|
│ └── momentum.py # Momentum-Strategie
|
||||||
|
└── risk/
|
||||||
|
├── __init__.py
|
||||||
|
└── risk_guard.py # Hard Risk Guardrails & Circuit Breaker
|
||||||
|
```
|
||||||
11
apex-trading/README.md
Normal file
11
apex-trading/README.md
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
# Apex Trading Subsystem
|
||||||
|
|
||||||
|
Quantitative & High-Frequency Trading Subsystem of the evOo Platform.
|
||||||
|
|
||||||
|
## Features
|
||||||
|
- Hard-coded risk controls (`src/risk/risk_guard.py`)
|
||||||
|
- Quantitative momentum and imbalance strategies (`src/strategies/`)
|
||||||
|
- Autonomous execution engine (`src/engine/trader.py`)
|
||||||
|
- Full integration with evOo Self-Improvement and CTO Steve Jobs directives.
|
||||||
|
|
||||||
|
See [BLUEPRINT.md](file:///Users/ingowagner/awesome-grok-bot/apex-trading/BLUEPRINT.md) for full architecture details.
|
||||||
0
apex-trading/__init__.py
Normal file
0
apex-trading/__init__.py
Normal file
29
apex-trading/config/trading.config.json
Normal file
29
apex-trading/config/trading.config.json
Normal file
@ -0,0 +1,29 @@
|
|||||||
|
{
|
||||||
|
"system": "Apex Trading Engine",
|
||||||
|
"version": "1.0.0-PROD",
|
||||||
|
"mode": "paper_trading",
|
||||||
|
"base_currency": "USDC",
|
||||||
|
"allocated_capital": 100000.0,
|
||||||
|
"risk_limits": {
|
||||||
|
"max_drawdown_percent": 1.5,
|
||||||
|
"max_single_trade_risk_percent": 0.5,
|
||||||
|
"max_open_positions": 4,
|
||||||
|
"max_leverage": 3.0,
|
||||||
|
"slippage_tolerance_percent": 0.15,
|
||||||
|
"circuit_breaker_cooldown_seconds": 3600
|
||||||
|
},
|
||||||
|
"active_pairs": ["BTC/USDC", "ETH/USDC", "SOL/USDC"],
|
||||||
|
"strategies": {
|
||||||
|
"momentum": {
|
||||||
|
"enabled": true,
|
||||||
|
"lookback_period": 20,
|
||||||
|
"rsi_oversold": 32,
|
||||||
|
"rsi_overbought": 68
|
||||||
|
},
|
||||||
|
"orderbook_imbalance": {
|
||||||
|
"enabled": true,
|
||||||
|
"threshold": 0.65,
|
||||||
|
"depth_levels": 10
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
0
apex-trading/src/__init__.py
Normal file
0
apex-trading/src/__init__.py
Normal file
0
apex-trading/src/engine/__init__.py
Normal file
0
apex-trading/src/engine/__init__.py
Normal file
62
apex-trading/src/engine/trader.py
Normal file
62
apex-trading/src/engine/trader.py
Normal file
@ -0,0 +1,62 @@
|
|||||||
|
"""
|
||||||
|
Apex Trading Engine Core
|
||||||
|
Event loop that receives signals, checks risk guardrails, and executes orders.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
from typing import Dict, Any
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
SRC_DIR = Path(__file__).resolve().parent.parent
|
||||||
|
if str(SRC_DIR) not in sys.path:
|
||||||
|
sys.path.insert(0, str(SRC_DIR))
|
||||||
|
|
||||||
|
try:
|
||||||
|
from risk.risk_guard import ApexRiskGuard
|
||||||
|
from strategies.momentum import MomentumStrategy
|
||||||
|
except ImportError:
|
||||||
|
from ..risk.risk_guard import ApexRiskGuard
|
||||||
|
from ..strategies.momentum import MomentumStrategy
|
||||||
|
|
||||||
|
class ApexEngine:
|
||||||
|
def __init__(self, config_path: str):
|
||||||
|
with open(config_path, 'r', encoding='utf-8') as f:
|
||||||
|
self.config = json.load(f)
|
||||||
|
|
||||||
|
self.risk_guard = ApexRiskGuard(self.config)
|
||||||
|
self.momentum_strategy = MomentumStrategy(self.config.get("strategies", {}).get("momentum"))
|
||||||
|
self.open_positions = []
|
||||||
|
self.current_drawdown_pct = 0.0
|
||||||
|
|
||||||
|
def process_tick(self, symbol: str, prices: list, volume: list) -> Dict[str, Any]:
|
||||||
|
"""Processes market data tick through strategy and risk guard."""
|
||||||
|
signal = self.momentum_strategy.generate_signal(symbol, prices, volume)
|
||||||
|
if not signal:
|
||||||
|
return {"status": "NO_SIGNAL", "symbol": symbol}
|
||||||
|
|
||||||
|
approved, reason = self.risk_guard.validate_order(
|
||||||
|
signal,
|
||||||
|
self.current_drawdown_pct,
|
||||||
|
len(self.open_positions)
|
||||||
|
)
|
||||||
|
|
||||||
|
if not approved:
|
||||||
|
return {"status": "REJECTED_BY_RISK", "reason": reason, "signal": signal}
|
||||||
|
|
||||||
|
# Simulated Execution
|
||||||
|
order_receipt = {
|
||||||
|
"status": "EXECUTED",
|
||||||
|
"symbol": signal["symbol"],
|
||||||
|
"action": signal["action"],
|
||||||
|
"fill_price": signal["price"],
|
||||||
|
"strategy": signal["strategy"],
|
||||||
|
"risk_status": "APPROVED"
|
||||||
|
}
|
||||||
|
self.open_positions.append(order_receipt)
|
||||||
|
return order_receipt
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
print("[APEX ENGINE] Liveness check passed. Engine ready.")
|
||||||
0
apex-trading/src/risk/__init__.py
Normal file
0
apex-trading/src/risk/__init__.py
Normal file
40
apex-trading/src/risk/risk_guard.py
Normal file
40
apex-trading/src/risk/risk_guard.py
Normal file
@ -0,0 +1,40 @@
|
|||||||
|
"""
|
||||||
|
Apex Trading Risk Guard
|
||||||
|
Hard enforcement of risk constraints defined in BLUEPRINT.md.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from typing import Dict, Any, Tuple
|
||||||
|
|
||||||
|
class ApexRiskGuard:
|
||||||
|
def __init__(self, config: Dict[str, Any]):
|
||||||
|
self.config = config.get("risk_limits", {})
|
||||||
|
self.max_drawdown = self.config.get("max_drawdown_percent", 1.5)
|
||||||
|
self.max_trade_risk = self.config.get("max_single_trade_risk_percent", 0.5)
|
||||||
|
self.max_positions = self.config.get("max_open_positions", 4)
|
||||||
|
self.circuit_breaker_active = False
|
||||||
|
|
||||||
|
def validate_order(self, order: Dict[str, Any], current_drawdown: float, open_position_count: int) -> Tuple[bool, str]:
|
||||||
|
"""
|
||||||
|
Validates an incoming order against hard guardrails.
|
||||||
|
Returns (is_approved: bool, reason: str).
|
||||||
|
"""
|
||||||
|
if self.circuit_breaker_active:
|
||||||
|
return False, "REJECTED: Circuit breaker is active."
|
||||||
|
|
||||||
|
if current_drawdown >= self.max_drawdown:
|
||||||
|
self.circuit_breaker_active = True
|
||||||
|
return False, f"REJECTED & CIRCUIT BREAKER TRIGGERED: Drawdown {current_drawdown}% >= limit {self.max_drawdown}%"
|
||||||
|
|
||||||
|
if open_position_count >= self.max_positions:
|
||||||
|
return False, f"REJECTED: Maximum concurrent positions ({self.max_positions}) reached."
|
||||||
|
|
||||||
|
order_risk_pct = order.get("risk_percent", 0.0)
|
||||||
|
if order_risk_pct > self.max_trade_risk:
|
||||||
|
return False, f"REJECTED: Order risk {order_risk_pct}% exceeds limit {self.max_trade_risk}%"
|
||||||
|
|
||||||
|
return True, "APPROVED"
|
||||||
|
|
||||||
|
def trip_kill_switch(self, reason: str):
|
||||||
|
"""Emergency hard stop."""
|
||||||
|
self.circuit_breaker_active = True
|
||||||
|
print(f"[APEX EMERGENCY KILL SWITCH] {reason}")
|
||||||
0
apex-trading/src/strategies/__init__.py
Normal file
0
apex-trading/src/strategies/__init__.py
Normal file
48
apex-trading/src/strategies/momentum.py
Normal file
48
apex-trading/src/strategies/momentum.py
Normal file
@ -0,0 +1,48 @@
|
|||||||
|
"""
|
||||||
|
Apex Momentum Strategy Module (Alpha-1)
|
||||||
|
Calculates breakout and trend-following signals.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from typing import List, Dict, Any, Optional
|
||||||
|
|
||||||
|
class MomentumStrategy:
|
||||||
|
def __init__(self, params: Optional[Dict[str, Any]] = None):
|
||||||
|
self.params = params or {}
|
||||||
|
self.lookback = self.params.get("lookback_period", 20)
|
||||||
|
self.rsi_oversold = self.params.get("rsi_oversold", 32)
|
||||||
|
self.rsi_overbought = self.params.get("rsi_overbought", 68)
|
||||||
|
|
||||||
|
def generate_signal(self, symbol: str, prices: List[float], volume: List[float]) -> Optional[Dict[str, Any]]:
|
||||||
|
"""
|
||||||
|
Evaluates recent tick series and outputs a trading signal if criteria are satisfied.
|
||||||
|
"""
|
||||||
|
if len(prices) < self.lookback:
|
||||||
|
return None
|
||||||
|
|
||||||
|
recent_window = prices[-self.lookback:]
|
||||||
|
current_price = prices[-1]
|
||||||
|
sma = sum(recent_window) / len(recent_window)
|
||||||
|
|
||||||
|
# Simple momentum breakout check
|
||||||
|
if current_price > sma * 1.015:
|
||||||
|
return {
|
||||||
|
"symbol": symbol,
|
||||||
|
"action": "BUY",
|
||||||
|
"price": current_price,
|
||||||
|
"strategy": "Alpha-1:Momentum",
|
||||||
|
"risk_percent": 0.35,
|
||||||
|
"stop_loss": current_price * 0.985,
|
||||||
|
"take_profit": current_price * 1.045
|
||||||
|
}
|
||||||
|
elif current_price < sma * 0.985:
|
||||||
|
return {
|
||||||
|
"symbol": symbol,
|
||||||
|
"action": "SELL",
|
||||||
|
"price": current_price,
|
||||||
|
"strategy": "Alpha-1:Momentum",
|
||||||
|
"risk_percent": 0.35,
|
||||||
|
"stop_loss": current_price * 1.015,
|
||||||
|
"take_profit": current_price * 0.955
|
||||||
|
}
|
||||||
|
|
||||||
|
return None
|
||||||
52
apex-trading/test_trading.py
Normal file
52
apex-trading/test_trading.py
Normal file
@ -0,0 +1,52 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Unit tests for Apex Trading Engine and Risk Guardrails.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import unittest
|
||||||
|
from pathlib import Path
|
||||||
|
import sys
|
||||||
|
|
||||||
|
BASE_DIR = Path(__file__).resolve().parent
|
||||||
|
if str(BASE_DIR / "src") not in sys.path:
|
||||||
|
sys.path.insert(0, str(BASE_DIR / "src"))
|
||||||
|
|
||||||
|
from engine.trader import ApexEngine
|
||||||
|
from risk.risk_guard import ApexRiskGuard
|
||||||
|
from strategies.momentum import MomentumStrategy
|
||||||
|
|
||||||
|
class TestApexTrading(unittest.TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.config_path = str(BASE_DIR / "config" / "trading.config.json")
|
||||||
|
self.engine = ApexEngine(self.config_path)
|
||||||
|
|
||||||
|
def test_risk_guard_limits(self):
|
||||||
|
guard = self.engine.risk_guard
|
||||||
|
# Normal order passes
|
||||||
|
normal_order = {"symbol": "BTC/USDT", "risk_percent": 0.3}
|
||||||
|
approved, reason = guard.validate_order(normal_order, current_drawdown=0.5, open_position_count=1)
|
||||||
|
self.assertTrue(approved)
|
||||||
|
self.assertEqual(reason, "APPROVED")
|
||||||
|
|
||||||
|
# Exceeding single trade risk
|
||||||
|
risky_order = {"symbol": "BTC/USDT", "risk_percent": 0.8}
|
||||||
|
approved, reason = guard.validate_order(risky_order, current_drawdown=0.5, open_position_count=1)
|
||||||
|
self.assertFalse(approved)
|
||||||
|
self.assertIn("exceeds limit", reason)
|
||||||
|
|
||||||
|
# Exceeding max drawdown trips circuit breaker
|
||||||
|
approved, reason = guard.validate_order(normal_order, current_drawdown=1.6, open_position_count=1)
|
||||||
|
self.assertFalse(approved)
|
||||||
|
self.assertIn("CIRCUIT BREAKER", reason)
|
||||||
|
self.assertTrue(guard.circuit_breaker_active)
|
||||||
|
|
||||||
|
def test_engine_process_tick(self):
|
||||||
|
# 25 prices trending sharply upwards
|
||||||
|
prices = [100.0 + (i * 1.5) for i in range(25)]
|
||||||
|
volume = [1000.0] * 25
|
||||||
|
result = self.engine.process_tick("SOL/USDT", prices, volume)
|
||||||
|
self.assertEqual(result.get("status"), "EXECUTED")
|
||||||
|
self.assertEqual(result.get("action"), "BUY")
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@ -29,7 +29,6 @@ function setup() {
|
|||||||
},
|
},
|
||||||
spaceMember: { findUnique: vi.fn(async () => ({ role: "owner" })) },
|
spaceMember: { findUnique: vi.fn(async () => ({ role: "owner" })) },
|
||||||
deploymentSettings: { findUnique: vi.fn(async () => null) },
|
deploymentSettings: { findUnique: vi.fn(async () => null) },
|
||||||
integrationProviderConfig: { findUnique: vi.fn(async () => null) },
|
|
||||||
aiDataConsent: { findMany: vi.fn(async () => []), upsert },
|
aiDataConsent: { findMany: vi.fn(async () => []), upsert },
|
||||||
$transaction: vi.fn(async (queries) => Promise.all(queries)),
|
$transaction: vi.fn(async (queries) => Promise.all(queries)),
|
||||||
},
|
},
|
||||||
@ -135,49 +134,6 @@ describe("consent grants", () => {
|
|||||||
expect(JSON.stringify(status.recipients)).not.toContain("supermemory");
|
expect(JSON.stringify(status.recipients)).not.toContain("supermemory");
|
||||||
expect(deps.prisma.spaceMemoryConfig.findUnique).not.toHaveBeenCalled();
|
expect(deps.prisma.spaceMemoryConfig.findUnique).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
it("discloses the deployment's media servers before model use, keyed by server", async () => {
|
|
||||||
const { deps, actor } = setup();
|
|
||||||
const settings = {
|
|
||||||
comfyui: { baseUrl: "http://127.0.0.1:8188", apiKey: "comfy-key", imageModel: "sdxl" },
|
|
||||||
transcription: { baseUrl: "http://127.0.0.1:8080/v1", model: "whisper-1" },
|
|
||||||
};
|
|
||||||
vi.mocked(deps.prisma.integrationProviderConfig.findUnique).mockResolvedValue({
|
|
||||||
id: "media",
|
|
||||||
ciphertext: "fake-media-ciphertext",
|
|
||||||
} as never);
|
|
||||||
deps.secrets = {
|
|
||||||
load: vi.fn(() => JSON.stringify(settings)),
|
|
||||||
} as unknown as RouterDeps["secrets"];
|
|
||||||
const status = await aiConsentStatus(deps, actor, { botId: "bot", uses: ["model"] });
|
|
||||||
expect(status.recipients.map((recipient) => recipient.name)).toEqual([
|
|
||||||
"ComfyUI (http://127.0.0.1:8188)",
|
|
||||||
"Transcription (http://127.0.0.1:8080)",
|
|
||||||
]);
|
|
||||||
expect(status.recipients[0]?.detail).toBe("Model: sdxl.");
|
|
||||||
expect(JSON.stringify(status)).not.toContain("comfy-key");
|
|
||||||
expect((await aiConsentStatus(deps, actor, { uses: ["voice"] })).recipients).toHaveLength(1);
|
|
||||||
});
|
|
||||||
it("discloses the transcription server for dictation when the voice provider cannot transcribe", async () => {
|
|
||||||
const { deps, actor } = setup();
|
|
||||||
vi.mocked(deps.prisma.integrationProviderConfig.findUnique).mockResolvedValue({
|
|
||||||
id: "media",
|
|
||||||
ciphertext: "fake-media-ciphertext-voice",
|
|
||||||
} as never);
|
|
||||||
deps.secrets = {
|
|
||||||
load: vi.fn(() =>
|
|
||||||
JSON.stringify({ comfyui: null, transcription: { baseUrl: "http://127.0.0.1:8080/v1" } }),
|
|
||||||
),
|
|
||||||
} as unknown as RouterDeps["secrets"];
|
|
||||||
vi.mocked(deps.prisma.spaceVoicePreference.findFirst).mockResolvedValue({
|
|
||||||
credential: { provider: "kokoro" },
|
|
||||||
voiceId: "af_heart",
|
|
||||||
} as never);
|
|
||||||
const status = await aiConsentStatus(deps, actor, { uses: ["voice"] });
|
|
||||||
expect(status.recipients.map((recipient) => [recipient.name, recipient.use])).toEqual([
|
|
||||||
["Local voice", "voice"],
|
|
||||||
["Transcription (http://127.0.0.1:8080)", "voice"],
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
it("returns an operator policy without requiring a hosted provider", async () => {
|
it("returns an operator policy without requiring a hosted provider", async () => {
|
||||||
const { deps, actor } = setup();
|
const { deps, actor } = setup();
|
||||||
deps.env.privacyPolicyUrl = "https://example.com/privacy";
|
deps.env.privacyPolicyUrl = "https://example.com/privacy";
|
||||||
|
|||||||
@ -3,13 +3,9 @@ import { ORPCError } from "@orpc/server";
|
|||||||
import {
|
import {
|
||||||
aiRecipient,
|
aiRecipient,
|
||||||
cloudAgentsEnabled,
|
cloudAgentsEnabled,
|
||||||
loadMediaSettings,
|
|
||||||
MEDIA_COMFYUI_PROVIDER_ID,
|
|
||||||
MEDIA_TRANSCRIPTION_PROVIDER_ID,
|
|
||||||
parseModelSecret,
|
parseModelSecret,
|
||||||
selectConfiguredModel,
|
selectConfiguredModel,
|
||||||
toStringRecord,
|
toStringRecord,
|
||||||
voiceCatalogEntry,
|
|
||||||
} from "@rakazo/adapters";
|
} from "@rakazo/adapters";
|
||||||
import type { Actor, AiConsentQuery, AiConsentStatus, AiRecipient } from "@rakazo/contracts";
|
import type { Actor, AiConsentQuery, AiConsentStatus, AiRecipient } from "@rakazo/contracts";
|
||||||
import { AI_DATA_DISCLOSURES, AI_DISCLOSURE_VERSION, AI_PRIVACY_URL } from "@rakazo/contracts";
|
import { AI_DATA_DISCLOSURES, AI_DISCLOSURE_VERSION, AI_PRIVACY_URL } from "@rakazo/contracts";
|
||||||
@ -187,21 +183,6 @@ export async function aiConsentStatus(
|
|||||||
}
|
}
|
||||||
for (const voice of voices)
|
for (const voice of voices)
|
||||||
add(aiRecipient({ provider: voice.credential.provider, use: "voice" }));
|
add(aiRecipient({ provider: voice.credential.provider, use: "voice" }));
|
||||||
if (uses.includes("voice")) {
|
|
||||||
// Dictation goes to the owner's transcription server when the voice provider cannot transcribe.
|
|
||||||
const transcription = (await loadMediaSettings(deps)).transcription;
|
|
||||||
const credential = transcription ? await findDefaultVoiceCredential(deps.prisma, actor) : null;
|
|
||||||
if (transcription && !(credential && voiceCatalogEntry(credential.provider)?.transcribe)) {
|
|
||||||
add(
|
|
||||||
aiRecipient({
|
|
||||||
provider: MEDIA_TRANSCRIPTION_PROVIDER_ID,
|
|
||||||
use: "voice",
|
|
||||||
baseUrl: transcription.baseUrl,
|
|
||||||
modelId: transcription.model,
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (includesMemory) {
|
if (includesMemory) {
|
||||||
if (memoryOwner) {
|
if (memoryOwner) {
|
||||||
const memory = await deps.prisma.spaceMemoryConfig.findUnique({
|
const memory = await deps.prisma.spaceMemoryConfig.findUnique({
|
||||||
@ -229,30 +210,6 @@ export async function aiConsentStatus(
|
|||||||
) {
|
) {
|
||||||
add(aiRecipient({ provider: deps.cloudAgent.provider.describe().id, use: "model" }));
|
add(aiRecipient({ provider: deps.cloudAgent.provider.describe().id, use: "model" }));
|
||||||
}
|
}
|
||||||
if (uses.includes("model")) {
|
|
||||||
// Media tools and card actions send prompts, images, video and audio to the owner's servers.
|
|
||||||
const media = await loadMediaSettings(deps);
|
|
||||||
if (media.comfyui) {
|
|
||||||
add(
|
|
||||||
aiRecipient({
|
|
||||||
provider: MEDIA_COMFYUI_PROVIDER_ID,
|
|
||||||
use: "model",
|
|
||||||
baseUrl: media.comfyui.baseUrl,
|
|
||||||
modelId: media.comfyui.imageModel,
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (media.transcription) {
|
|
||||||
add(
|
|
||||||
aiRecipient({
|
|
||||||
provider: MEDIA_TRANSCRIPTION_PROVIDER_ID,
|
|
||||||
use: "model",
|
|
||||||
baseUrl: media.transcription.baseUrl,
|
|
||||||
modelId: media.transcription.model,
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return {
|
return {
|
||||||
scope: createHash("sha256")
|
scope: createHash("sha256")
|
||||||
.update(JSON.stringify([actor.userId, actor.spaceId]))
|
.update(JSON.stringify([actor.userId, actor.spaceId]))
|
||||||
|
|||||||
@ -1,4 +1,5 @@
|
|||||||
import { randomUUID } from "node:crypto";
|
import { randomUUID } from "node:crypto";
|
||||||
|
import { rm } from "node:fs/promises";
|
||||||
import { ORPCError, onError } from "@orpc/server";
|
import { ORPCError, onError } from "@orpc/server";
|
||||||
import { RPCHandler } from "@orpc/server/fetch";
|
import { RPCHandler } from "@orpc/server/fetch";
|
||||||
import type {
|
import type {
|
||||||
@ -14,7 +15,6 @@ import type {
|
|||||||
ComposioProvider,
|
ComposioProvider,
|
||||||
ConnectorRegistry,
|
ConnectorRegistry,
|
||||||
DestinationEmulator,
|
DestinationEmulator,
|
||||||
MediaOperationRegistration,
|
|
||||||
RemoteConnectorDependencies,
|
RemoteConnectorDependencies,
|
||||||
} from "@rakazo/adapters";
|
} from "@rakazo/adapters";
|
||||||
import {
|
import {
|
||||||
@ -25,8 +25,6 @@ import {
|
|||||||
createCloudAgentConnection,
|
createCloudAgentConnection,
|
||||||
createConnectorStack,
|
createConnectorStack,
|
||||||
createJobReconciler,
|
createJobReconciler,
|
||||||
createMediaOperations,
|
|
||||||
createMediaRuntime,
|
|
||||||
createMessagingContextLoader,
|
createMessagingContextLoader,
|
||||||
createMessagingTeamChatSender,
|
createMessagingTeamChatSender,
|
||||||
createProfileConsolidationScheduler,
|
createProfileConsolidationScheduler,
|
||||||
@ -37,20 +35,18 @@ import {
|
|||||||
destroyBot,
|
destroyBot,
|
||||||
EmailEmulator,
|
EmailEmulator,
|
||||||
EncryptedSecretStore,
|
EncryptedSecretStore,
|
||||||
ExpoPushSender,
|
ExpoPushProvider,
|
||||||
GraphileJobPublisher,
|
GraphileJobPublisher,
|
||||||
GraphileJobWorkerHost,
|
GraphileJobWorkerHost,
|
||||||
InMemoryJobQueue,
|
InMemoryJobQueue,
|
||||||
InMemoryRealtimeFanout,
|
InMemoryRealtimeFanout,
|
||||||
InstalledConnectorProvider,
|
InstalledConnectorProvider,
|
||||||
IntegrationProviderSettings,
|
IntegrationProviderSettings,
|
||||||
importLegacyPushTokens,
|
|
||||||
isComposioEnabled,
|
isComposioEnabled,
|
||||||
isMessagingSurfaceEnabled,
|
isMessagingSurfaceEnabled,
|
||||||
isPipedreamEnabled,
|
isPipedreamEnabled,
|
||||||
LocalAgentHomeStore,
|
LocalAgentHomeStore,
|
||||||
LocalArtifactStore,
|
LocalArtifactStore,
|
||||||
loadMediaSettings,
|
|
||||||
McpConnector,
|
McpConnector,
|
||||||
McpOAuthBroker,
|
McpOAuthBroker,
|
||||||
messagingPlatformsFromEnv,
|
messagingPlatformsFromEnv,
|
||||||
@ -58,9 +54,9 @@ import {
|
|||||||
PiOAuthLogins,
|
PiOAuthLogins,
|
||||||
PipedreamConnector,
|
PipedreamConnector,
|
||||||
PostgresRealtimeFanout,
|
PostgresRealtimeFanout,
|
||||||
PushNotificationProvider,
|
|
||||||
pipedreamConfigFromEnv,
|
pipedreamConfigFromEnv,
|
||||||
piSessionsRoot,
|
piSessionsRoot,
|
||||||
|
pushTokenPath,
|
||||||
reconcileCloudAgents,
|
reconcileCloudAgents,
|
||||||
reconcileComputerUpdates,
|
reconcileComputerUpdates,
|
||||||
reconcileDelegatedTasks,
|
reconcileDelegatedTasks,
|
||||||
@ -69,9 +65,6 @@ import {
|
|||||||
SmtpEmailProvider,
|
SmtpEmailProvider,
|
||||||
SpaceMemoryProviderResolver,
|
SpaceMemoryProviderResolver,
|
||||||
toTeamChatInbound,
|
toTeamChatInbound,
|
||||||
VapidKeys,
|
|
||||||
vapidEnvFromProcess,
|
|
||||||
WebPushSender,
|
|
||||||
} from "@rakazo/adapters";
|
} from "@rakazo/adapters";
|
||||||
import { blockedAuthPaths, createAuth } from "@rakazo/auth";
|
import { blockedAuthPaths, createAuth } from "@rakazo/auth";
|
||||||
import { signupPolicyFromEnv } from "@rakazo/core";
|
import { signupPolicyFromEnv } from "@rakazo/core";
|
||||||
@ -96,7 +89,6 @@ import { requestLogging } from "@rakazo/logging/hono";
|
|||||||
import { MarkdownMemoryStore } from "@rakazo/memory";
|
import { MarkdownMemoryStore } from "@rakazo/memory";
|
||||||
import { Hono } from "hono";
|
import { Hono } from "hono";
|
||||||
import { cors } from "hono/cors";
|
import { cors } from "hono/cors";
|
||||||
import { mountArtifactHttpRoutes } from "./artifact-http.js";
|
|
||||||
import type { AppEnv } from "./env.js";
|
import type { AppEnv } from "./env.js";
|
||||||
import { loadEnv } from "./env.js";
|
import { loadEnv } from "./env.js";
|
||||||
import { mountLocalSettings } from "./local-settings.js";
|
import { mountLocalSettings } from "./local-settings.js";
|
||||||
@ -117,6 +109,7 @@ import {
|
|||||||
settleWithTimeout,
|
settleWithTimeout,
|
||||||
TEAM_CHAT_STARTUP_SHUTDOWN_MS,
|
TEAM_CHAT_STARTUP_SHUTDOWN_MS,
|
||||||
} from "./team-chat-startup.js";
|
} from "./team-chat-startup.js";
|
||||||
|
import { mountTradingBotStatus } from "./trading-bot-proxy.js";
|
||||||
import { mountVoiceHttpRoutes } from "./voice.js";
|
import { mountVoiceHttpRoutes } from "./voice.js";
|
||||||
import { mountWebhookHttpRoutes } from "./webhook.js";
|
import { mountWebhookHttpRoutes } from "./webhook.js";
|
||||||
|
|
||||||
@ -146,8 +139,6 @@ export async function createApp(
|
|||||||
email?: TransactionalEmailProvider;
|
email?: TransactionalEmailProvider;
|
||||||
remoteConnectors?: RemoteConnectorDependencies;
|
remoteConnectors?: RemoteConnectorDependencies;
|
||||||
logger?: Logger;
|
logger?: Logger;
|
||||||
/** Test seam: media operations instead of the configured ones. */
|
|
||||||
mediaOperations?: MediaOperationRegistration[];
|
|
||||||
} = {},
|
} = {},
|
||||||
): Promise<AppHandles> {
|
): Promise<AppHandles> {
|
||||||
const {
|
const {
|
||||||
@ -160,7 +151,6 @@ export async function createApp(
|
|||||||
email: emailOverride,
|
email: emailOverride,
|
||||||
remoteConnectors,
|
remoteConnectors,
|
||||||
logger: loggerOverride,
|
logger: loggerOverride,
|
||||||
mediaOperations,
|
|
||||||
...envOverrides
|
...envOverrides
|
||||||
} = overrides;
|
} = overrides;
|
||||||
const env = { ...loadEnv(process.env), ...envOverrides };
|
const env = { ...loadEnv(process.env), ...envOverrides };
|
||||||
@ -319,14 +309,7 @@ export async function createApp(
|
|||||||
: new PiAgentRuntime({
|
: new PiAgentRuntime({
|
||||||
sessionRoot: env.piSessionRecording ? piSessionsRoot(env.dataDir) : undefined,
|
sessionRoot: env.piSessionRecording ? piSessionsRoot(env.dataDir) : undefined,
|
||||||
});
|
});
|
||||||
const vapid = new VapidKeys(prisma, secrets, vapidEnvFromProcess(process.env));
|
const notifications = new ExpoPushProvider(env.dataDir);
|
||||||
const notifications = new PushNotificationProvider(prisma, {
|
|
||||||
webpush: new WebPushSender(vapid),
|
|
||||||
expo: new ExpoPushSender(),
|
|
||||||
});
|
|
||||||
void importLegacyPushTokens(prisma, env.dataDir).catch((error) =>
|
|
||||||
getLogger().error("legacy push token import failed", error),
|
|
||||||
);
|
|
||||||
const auth = createAuth(prisma, {
|
const auth = createAuth(prisma, {
|
||||||
secret: env.authSecret,
|
secret: env.authSecret,
|
||||||
baseURL: env.authUrl,
|
baseURL: env.authUrl,
|
||||||
@ -382,6 +365,7 @@ export async function createApp(
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
await removePiUserSessions(env.dataDir, userId);
|
await removePiUserSessions(env.dataDir, userId);
|
||||||
|
await rm(pushTokenPath(env.dataDir, userId), { force: true }).catch(() => undefined);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
// One provider instance so emulator launches and polls share the same Map.
|
// One provider instance so emulator launches and polls share the same Map.
|
||||||
@ -391,15 +375,6 @@ export async function createApp(
|
|||||||
CLOUD_AGENT_SPACE_ID: env.cloudAgentSpaceId,
|
CLOUD_AGENT_SPACE_ID: env.cloudAgentSpaceId,
|
||||||
});
|
});
|
||||||
const shutdown = new AbortController();
|
const shutdown = new AbortController();
|
||||||
const media = createMediaRuntime({
|
|
||||||
prisma,
|
|
||||||
events,
|
|
||||||
jobs,
|
|
||||||
artifacts,
|
|
||||||
registrations:
|
|
||||||
mediaOperations ??
|
|
||||||
createMediaOperations({ loadSettings: () => loadMediaSettings({ prisma, secrets }) }),
|
|
||||||
});
|
|
||||||
const executor = createRunExecutor({
|
const executor = createRunExecutor({
|
||||||
prisma,
|
prisma,
|
||||||
runtime,
|
runtime,
|
||||||
@ -436,7 +411,6 @@ export async function createApp(
|
|||||||
messaging: messaging ? createMessagingContextLoader(prisma) : undefined,
|
messaging: messaging ? createMessagingContextLoader(prisma) : undefined,
|
||||||
web: createWebProvider(),
|
web: createWebProvider(),
|
||||||
cloudAgent,
|
cloudAgent,
|
||||||
media,
|
|
||||||
shutdownSignal: shutdown.signal,
|
shutdownSignal: shutdown.signal,
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -454,7 +428,6 @@ export async function createApp(
|
|||||||
deploymentModelKey: env.deploymentModelKey,
|
deploymentModelKey: env.deploymentModelKey,
|
||||||
messaging,
|
messaging,
|
||||||
cloudAgent,
|
cloudAgent,
|
||||||
media,
|
|
||||||
});
|
});
|
||||||
if (inMemoryJobs) {
|
if (inMemoryJobs) {
|
||||||
await inMemoryJobs.start(jobHandlers);
|
await inMemoryJobs.start(jobHandlers);
|
||||||
@ -473,14 +446,12 @@ export async function createApp(
|
|||||||
reconcileComputerUpdates: () => reconcileComputerUpdates({ prisma, jobs }),
|
reconcileComputerUpdates: () => reconcileComputerUpdates({ prisma, jobs }),
|
||||||
reconcileProfileConsolidation: createProfileConsolidationScheduler({ prisma, jobs }),
|
reconcileProfileConsolidation: createProfileConsolidationScheduler({ prisma, jobs }),
|
||||||
reconcileDelegatedTasks: () => reconcileDelegatedTasks({ prisma, jobs, events }),
|
reconcileDelegatedTasks: () => reconcileDelegatedTasks({ prisma, jobs, events }),
|
||||||
reconcileMediaJobs: () => media.reconcile(),
|
|
||||||
})
|
})
|
||||||
: undefined;
|
: undefined;
|
||||||
reconciler?.start();
|
reconciler?.start();
|
||||||
|
|
||||||
const router = createRouter({
|
const router = createRouter({
|
||||||
cloudAgent,
|
cloudAgent,
|
||||||
media,
|
|
||||||
prisma,
|
prisma,
|
||||||
events,
|
events,
|
||||||
auth,
|
auth,
|
||||||
@ -492,7 +463,6 @@ export async function createApp(
|
|||||||
secrets,
|
secrets,
|
||||||
oauthLogins,
|
oauthLogins,
|
||||||
integrationSettings,
|
integrationSettings,
|
||||||
vapid,
|
|
||||||
mcpOAuth,
|
mcpOAuth,
|
||||||
composio: stack.composio,
|
composio: stack.composio,
|
||||||
connectors: stack.connector,
|
connectors: stack.connector,
|
||||||
@ -543,7 +513,7 @@ export async function createApp(
|
|||||||
await next();
|
await next();
|
||||||
c.res.headers.set(
|
c.res.headers.set(
|
||||||
"Content-Security-Policy",
|
"Content-Security-Policy",
|
||||||
"default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; media-src 'self' blob:; font-src 'self'; connect-src 'self' ws:; frame-ancestors 'none'; base-uri 'self'; form-action 'self'",
|
"default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; font-src 'self'; connect-src 'self' ws:; frame-ancestors 'none'; base-uri 'self'; form-action 'self'",
|
||||||
);
|
);
|
||||||
c.res.headers.set("Strict-Transport-Security", "max-age=31536000; includeSubDomains");
|
c.res.headers.set("Strict-Transport-Security", "max-age=31536000; includeSubDomains");
|
||||||
c.res.headers.set("X-Frame-Options", "DENY");
|
c.res.headers.set("X-Frame-Options", "DENY");
|
||||||
@ -560,6 +530,13 @@ export async function createApp(
|
|||||||
resetUrl: email ? new URL("/reset-password", env.webOrigin).href : null,
|
resetUrl: email ? new URL("/reset-password", env.webOrigin).href : null,
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
mountTradingBotStatus(app, {
|
||||||
|
baseUrl: env.tradingBotUrl,
|
||||||
|
authorize: async (request) => {
|
||||||
|
const session = await auth.api.getSession({ headers: sessionHeaders(request) });
|
||||||
|
return Boolean(session?.user);
|
||||||
|
},
|
||||||
|
});
|
||||||
if (localEmailEmulator && env.nodeEnv === "development") {
|
if (localEmailEmulator && env.nodeEnv === "development") {
|
||||||
app.get(
|
app.get(
|
||||||
"/api/dev/emails",
|
"/api/dev/emails",
|
||||||
@ -590,7 +567,7 @@ export async function createApp(
|
|||||||
}
|
}
|
||||||
const { matched, response } = await rpc.handle(c.req.raw, {
|
const { matched, response } = await rpc.handle(c.req.raw, {
|
||||||
prefix: "/rpc",
|
prefix: "/rpc",
|
||||||
context: { actor, signal: c.req.raw.signal, userAgent: c.req.header("user-agent") },
|
context: { actor, signal: c.req.raw.signal },
|
||||||
});
|
});
|
||||||
if (matched) return c.newResponse(response.body, response);
|
if (matched) return c.newResponse(response.body, response);
|
||||||
await next();
|
await next();
|
||||||
@ -606,13 +583,6 @@ export async function createApp(
|
|||||||
if (actor) enrichLogContext({ "user.id": actor.userId, "space.id": actor.spaceId });
|
if (actor) enrichLogContext({ "user.id": actor.userId, "space.id": actor.spaceId });
|
||||||
return actor;
|
return actor;
|
||||||
});
|
});
|
||||||
mountArtifactHttpRoutes(app, {
|
|
||||||
prisma,
|
|
||||||
artifacts,
|
|
||||||
sessionUserId: async (request) =>
|
|
||||||
(await auth.api.getSession({ headers: sessionHeaders(request) }))?.user?.id ?? null,
|
|
||||||
trustedOrigin: (origin) => isTrustedOrigin(origin, env),
|
|
||||||
});
|
|
||||||
mountWebhookHttpRoutes(app, { prisma, secrets, events, jobs });
|
mountWebhookHttpRoutes(app, { prisma, secrets, events, jobs });
|
||||||
// Shared with stop so a shutdown during retry delays does not restart polling.
|
// Shared with stop so a shutdown during retry delays does not restart polling.
|
||||||
let messagingStopped = false;
|
let messagingStopped = false;
|
||||||
|
|||||||
@ -1,275 +0,0 @@
|
|||||||
import { mkdtemp, readdir, rm } from "node:fs/promises";
|
|
||||||
import { createServer } from "node:http";
|
|
||||||
import { tmpdir } from "node:os";
|
|
||||||
import path from "node:path";
|
|
||||||
import type { ArtifactStore } from "@rakazo/adapter-kit";
|
|
||||||
import { LocalArtifactStore } from "@rakazo/adapters";
|
|
||||||
import type { PrismaClient } from "@rakazo/db";
|
|
||||||
import { Hono } from "hono";
|
|
||||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
||||||
import {
|
|
||||||
API_SERVER_OPTIONS,
|
|
||||||
contentDisposition,
|
|
||||||
mountArtifactHttpRoutes,
|
|
||||||
parseByteRange,
|
|
||||||
stallGuardedChunks,
|
|
||||||
UploadStalledError,
|
|
||||||
} from "./artifact-http.js";
|
|
||||||
|
|
||||||
describe("parseByteRange", () => {
|
|
||||||
it("ignores an absent header or another unit", () => {
|
|
||||||
expect(parseByteRange(undefined, 100)).toBeNull();
|
|
||||||
expect(parseByteRange("", 100)).toBeNull();
|
|
||||||
expect(parseByteRange("items=0-1", 100)).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("parses closed, open and suffix ranges and clamps the end to the size", () => {
|
|
||||||
expect(parseByteRange("bytes=0-9", 100)).toEqual({ start: 0, end: 9 });
|
|
||||||
expect(parseByteRange("bytes=90-", 100)).toEqual({ start: 90, end: 99 });
|
|
||||||
expect(parseByteRange("bytes=-10", 100)).toEqual({ start: 90, end: 99 });
|
|
||||||
expect(parseByteRange("bytes=-500", 100)).toEqual({ start: 0, end: 99 });
|
|
||||||
expect(parseByteRange("bytes=50-1000", 100)).toEqual({ start: 50, end: 99 });
|
|
||||||
expect(parseByteRange("Bytes= 0-0 ", 100)).toEqual({ start: 0, end: 0 });
|
|
||||||
});
|
|
||||||
|
|
||||||
it("rejects multiple, inverted, out-of-file and malformed ranges", () => {
|
|
||||||
for (const header of [
|
|
||||||
"bytes=0-1,5-6",
|
|
||||||
"bytes=5-4",
|
|
||||||
"bytes=100-",
|
|
||||||
"bytes=100-200",
|
|
||||||
"bytes=-0",
|
|
||||||
"bytes=-",
|
|
||||||
"bytes=a-b",
|
|
||||||
"bytes=1.5-2",
|
|
||||||
"bytes= -1-2",
|
|
||||||
"bytes=0x10-20",
|
|
||||||
`bytes=${"9".repeat(16)}-`,
|
|
||||||
]) {
|
|
||||||
expect(parseByteRange(header, 100), header).toBe("unsatisfiable");
|
|
||||||
}
|
|
||||||
expect(parseByteRange("bytes=0-0", 0)).toBe("unsatisfiable");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("contentDisposition", () => {
|
|
||||||
it("keeps plain names and marks media inline", () => {
|
|
||||||
expect(contentDisposition("clip.mp4", true)).toBe(
|
|
||||||
`inline; filename="clip.mp4"; filename*=UTF-8''clip.mp4`,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("neutralizes quotes, separators, control characters and header injection", () => {
|
|
||||||
const header = contentDisposition('../evil"\r\nSet-Cookie: x=1;.txt', false);
|
|
||||||
expect(header.startsWith("attachment; ")).toBe(true);
|
|
||||||
expect(header).not.toMatch(/[\r\n]/);
|
|
||||||
expect(header).toContain(`filename=".._evil___Set-Cookie: x=1_.txt"`);
|
|
||||||
expect(header).not.toContain("/");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("encodes non-ASCII names with an ASCII fallback", () => {
|
|
||||||
expect(contentDisposition("Grüße (1).png", true)).toBe(
|
|
||||||
`inline; filename="Gr__e (1).png"; filename*=UTF-8''Gr%C3%BC%C3%9Fe%20%281%29.png`,
|
|
||||||
);
|
|
||||||
expect(contentDisposition(" ", false)).toBe(
|
|
||||||
`attachment; filename="download"; filename*=UTF-8''download`,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("artifact HTTP routes", () => {
|
|
||||||
function app(sessionUserId: () => Promise<string | null>) {
|
|
||||||
const prisma = {
|
|
||||||
artifact: { findUnique: vi.fn(), create: vi.fn() },
|
|
||||||
spaceMember: { findFirst: vi.fn() },
|
|
||||||
};
|
|
||||||
const artifacts = { putStream: vi.fn(), stat: vi.fn(), createReadStream: vi.fn() };
|
|
||||||
const hono = new Hono();
|
|
||||||
mountArtifactHttpRoutes(hono, {
|
|
||||||
prisma: prisma as unknown as PrismaClient,
|
|
||||||
artifacts: artifacts as unknown as ArtifactStore,
|
|
||||||
sessionUserId,
|
|
||||||
trustedOrigin: (origin) => origin === "http://127.0.0.1:5173",
|
|
||||||
});
|
|
||||||
return { hono, prisma, artifacts };
|
|
||||||
}
|
|
||||||
|
|
||||||
it("rejects signed-out reads and uploads before touching storage", async () => {
|
|
||||||
const { hono, prisma, artifacts } = app(async () => null);
|
|
||||||
const read = await hono.request("/api/artifacts/art-1");
|
|
||||||
const upload = await hono.request("/api/artifacts?botId=bot-1&name=a.mp4", {
|
|
||||||
method: "POST",
|
|
||||||
headers: { "content-type": "video/mp4" },
|
|
||||||
body: "data",
|
|
||||||
});
|
|
||||||
expect(read.status).toBe(401);
|
|
||||||
expect(upload.status).toBe(401);
|
|
||||||
expect(prisma.artifact.findUnique).not.toHaveBeenCalled();
|
|
||||||
expect(artifacts.putStream).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("refuses uploads from untrusted origins before reading the session", async () => {
|
|
||||||
const sessionUserId = vi.fn(async () => "user-1");
|
|
||||||
const { hono, artifacts } = app(sessionUserId);
|
|
||||||
const response = await hono.request("/api/artifacts?botId=bot-1&name=a.txt", {
|
|
||||||
method: "POST",
|
|
||||||
headers: { "content-type": "text/plain", origin: "https://evil.example" },
|
|
||||||
body: "data",
|
|
||||||
});
|
|
||||||
expect(response.status).toBe(403);
|
|
||||||
expect(sessionUserId).not.toHaveBeenCalled();
|
|
||||||
expect(artifacts.putStream).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("answers ids that are not artifact ids with 404 without a lookup", async () => {
|
|
||||||
const { hono, prisma } = app(async () => "user-1");
|
|
||||||
const response = await hono.request(`/api/artifacts/${encodeURIComponent("../etc/passwd")}`);
|
|
||||||
expect(response.status).toBe(404);
|
|
||||||
expect(prisma.artifact.findUnique).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("rejects declared oversized and disallowed uploads without reading the body", async () => {
|
|
||||||
const { hono, prisma, artifacts } = app(async () => "user-1");
|
|
||||||
prisma.spaceMember.findFirst.mockResolvedValue({
|
|
||||||
userId: "user-1",
|
|
||||||
spaceId: "space-1",
|
|
||||||
member: { user: { email: "user@example.test" } },
|
|
||||||
});
|
|
||||||
(prisma as unknown as { deploymentSettings: unknown }).deploymentSettings = {
|
|
||||||
findUnique: vi.fn(async () => null),
|
|
||||||
};
|
|
||||||
const cancel = vi.fn(() => new Promise<void>(() => undefined));
|
|
||||||
const oversized = await hono.request(
|
|
||||||
new Request("http://localhost/api/artifacts?botId=bot-1&name=a.mp4", {
|
|
||||||
method: "POST",
|
|
||||||
headers: { "content-type": "video/mp4", "content-length": String(501 * 1024 * 1024) },
|
|
||||||
body: new ReadableStream({ cancel }),
|
|
||||||
duplex: "half",
|
|
||||||
} as RequestInit & { duplex: "half" }),
|
|
||||||
);
|
|
||||||
expect(oversized.status).toBe(413);
|
|
||||||
expect(cancel).toHaveBeenCalled();
|
|
||||||
|
|
||||||
const html = await hono.request("/api/artifacts?botId=bot-1&name=a.html", {
|
|
||||||
method: "POST",
|
|
||||||
headers: { "content-type": "text/html" },
|
|
||||||
body: "<script></script>",
|
|
||||||
});
|
|
||||||
expect(html.status).toBe(415);
|
|
||||||
|
|
||||||
const noTarget = await hono.request("/api/artifacts?name=a.mp4", {
|
|
||||||
method: "POST",
|
|
||||||
headers: { "content-type": "video/mp4" },
|
|
||||||
body: "data",
|
|
||||||
});
|
|
||||||
expect(noTarget.status).toBe(400);
|
|
||||||
expect(artifacts.putStream).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("upload timeouts", () => {
|
|
||||||
const dirs: string[] = [];
|
|
||||||
afterEach(async () => {
|
|
||||||
await Promise.all(dirs.splice(0).map((dir) => rm(dir, { recursive: true, force: true })));
|
|
||||||
});
|
|
||||||
|
|
||||||
it("gives the server time for large uploads but keeps the header timeout", () => {
|
|
||||||
const server = createServer(API_SERVER_OPTIONS);
|
|
||||||
expect(server.requestTimeout).toBe(30 * 60 * 1000);
|
|
||||||
expect(server.headersTimeout).toBe(createServer().headersTimeout);
|
|
||||||
});
|
|
||||||
|
|
||||||
/** A body that sends `chunks` at `gapMs` intervals, then stays open forever if `hang`. */
|
|
||||||
function drip(chunks: string[], gapMs: number, hang: boolean) {
|
|
||||||
const cancel = vi.fn(() => new Promise<void>(() => undefined));
|
|
||||||
let sent = 0;
|
|
||||||
const body = new ReadableStream<Uint8Array>({
|
|
||||||
async pull(controller) {
|
|
||||||
if (sent === chunks.length) {
|
|
||||||
if (!hang) controller.close();
|
|
||||||
else await new Promise(() => undefined);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (sent > 0) await new Promise((resolve) => setTimeout(resolve, gapMs));
|
|
||||||
controller.enqueue(new TextEncoder().encode(chunks[sent++]));
|
|
||||||
},
|
|
||||||
cancel,
|
|
||||||
});
|
|
||||||
return { body, cancel };
|
|
||||||
}
|
|
||||||
|
|
||||||
it("throws once the body stops sending and cancels it without waiting", async () => {
|
|
||||||
const { body, cancel } = drip(["ab", "cd"], 5, true);
|
|
||||||
const seen: string[] = [];
|
|
||||||
await expect(
|
|
||||||
(async () => {
|
|
||||||
for await (const chunk of stallGuardedChunks(body, 50)) {
|
|
||||||
seen.push(new TextDecoder().decode(chunk));
|
|
||||||
}
|
|
||||||
})(),
|
|
||||||
).rejects.toBeInstanceOf(UploadStalledError);
|
|
||||||
expect(seen).toEqual(["ab", "cd"]);
|
|
||||||
expect(cancel).toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
async function postDrip(stallMs: number, body: ReadableStream<Uint8Array>) {
|
|
||||||
const root = await mkdtemp(path.join(tmpdir(), "rakazo-artifact-upload-"));
|
|
||||||
dirs.push(root);
|
|
||||||
const create = vi.fn(async ({ data }: { data: Record<string, unknown> }) => ({
|
|
||||||
id: "art-1",
|
|
||||||
createdAt: new Date(),
|
|
||||||
...data,
|
|
||||||
}));
|
|
||||||
const prisma = {
|
|
||||||
artifact: { create },
|
|
||||||
spaceMember: {
|
|
||||||
findFirst: vi.fn(async () => ({
|
|
||||||
userId: "user-1",
|
|
||||||
spaceId: "space-1",
|
|
||||||
member: { user: { email: "user@example.test" } },
|
|
||||||
})),
|
|
||||||
},
|
|
||||||
deploymentSettings: { findUnique: vi.fn(async () => null) },
|
|
||||||
bot: { findFirst: vi.fn(async () => ({ id: "bot-1" })) },
|
|
||||||
};
|
|
||||||
const hono = new Hono();
|
|
||||||
mountArtifactHttpRoutes(hono, {
|
|
||||||
prisma: prisma as unknown as PrismaClient,
|
|
||||||
artifacts: new LocalArtifactStore(root),
|
|
||||||
sessionUserId: async () => "user-1",
|
|
||||||
trustedOrigin: () => true,
|
|
||||||
uploadStallTimeoutMs: stallMs,
|
|
||||||
});
|
|
||||||
const response = await hono.request(
|
|
||||||
new Request("http://localhost/api/artifacts?botId=bot-1&name=clip.mp4", {
|
|
||||||
method: "POST",
|
|
||||||
headers: { "content-type": "video/mp4" },
|
|
||||||
body,
|
|
||||||
duplex: "half",
|
|
||||||
} as RequestInit & { duplex: "half" }),
|
|
||||||
);
|
|
||||||
// The store creates the space directory before it reads the body, so it must exist here.
|
|
||||||
const stored = await readdir(path.join(root, "artifacts", "space-1"));
|
|
||||||
return { response, create, stored };
|
|
||||||
}
|
|
||||||
|
|
||||||
it("aborts a stalled upload with 408 and deletes the partial file", async () => {
|
|
||||||
const { body, cancel } = drip(["partial video bytes"], 0, true);
|
|
||||||
const { response, create, stored } = await postDrip(50, body);
|
|
||||||
expect(response.status).toBe(408);
|
|
||||||
expect(await response.json()).toEqual({ error: "Upload stalled." });
|
|
||||||
expect(cancel).toHaveBeenCalled();
|
|
||||||
expect(create).not.toHaveBeenCalled();
|
|
||||||
expect(stored).toEqual([]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("keeps a slow but steady upload going past the stall timeout", async () => {
|
|
||||||
const { body } = drip(["a", "b", "c", "d", "e", "f"], 30, false);
|
|
||||||
const { response, create, stored } = await postDrip(100, body);
|
|
||||||
expect(response.status).toBe(201);
|
|
||||||
expect(create).toHaveBeenCalledWith({
|
|
||||||
data: expect.objectContaining({ size: 6, mimeType: "video/mp4", botId: "bot-1" }),
|
|
||||||
});
|
|
||||||
expect(stored).toHaveLength(1);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@ -1,336 +0,0 @@
|
|||||||
import type { AdapterContext, ArtifactByteRange, ArtifactStore } from "@rakazo/adapter-kit";
|
|
||||||
import { ArtifactTooLargeError } from "@rakazo/adapter-kit";
|
|
||||||
import { artifactMaxBytes, storeArtifactStream } from "@rakazo/adapters";
|
|
||||||
import type { Actor } from "@rakazo/contracts";
|
|
||||||
import { attachmentKindOf, isAllowedAttachmentMimeType } from "@rakazo/contracts";
|
|
||||||
import type { PrismaClient } from "@rakazo/db";
|
|
||||||
import { createGroupRepos, createRepos, IsolationError, requireMembership } from "@rakazo/db";
|
|
||||||
import type { Context, Hono } from "hono";
|
|
||||||
import { cancelBody } from "./http-body.js";
|
|
||||||
|
|
||||||
export const ARTIFACT_HTTP_PATH = "/api/artifacts";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Node's default 5-minute requestTimeout cuts off a 500 MiB video upload on any uplink slower than
|
|
||||||
* about 14 Mbit/s; 30 minutes fits it at about 2.5 Mbit/s. headersTimeout keeps its default, and
|
|
||||||
* the per-upload stall timeout stops a slow-drip client from holding a connection that long.
|
|
||||||
*/
|
|
||||||
export const API_SERVER_OPTIONS = { requestTimeout: 30 * 60 * 1000 };
|
|
||||||
/** An upload that sends no bytes for this long is aborted and its partial file deleted. */
|
|
||||||
export const ARTIFACT_UPLOAD_STALL_TIMEOUT_MS = 60_000;
|
|
||||||
|
|
||||||
export class UploadStalledError extends Error {
|
|
||||||
constructor() {
|
|
||||||
super("Upload stalled");
|
|
||||||
this.name = "UploadStalledError";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Yields the body's chunks and throws UploadStalledError when the next chunk takes longer than
|
|
||||||
* stallMs. Only time spent waiting for the client counts, not time spent writing to disk.
|
|
||||||
*/
|
|
||||||
export async function* stallGuardedChunks(
|
|
||||||
body: ReadableStream<Uint8Array>,
|
|
||||||
stallMs: number,
|
|
||||||
): AsyncGenerator<Uint8Array> {
|
|
||||||
const reader = body.getReader();
|
|
||||||
try {
|
|
||||||
for (;;) {
|
|
||||||
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
||||||
const stalled = new Promise<never>((_, reject) => {
|
|
||||||
timer = setTimeout(() => reject(new UploadStalledError()), stallMs);
|
|
||||||
});
|
|
||||||
try {
|
|
||||||
const { done, value } = await Promise.race([reader.read(), stalled]);
|
|
||||||
if (done) return;
|
|
||||||
yield value;
|
|
||||||
} finally {
|
|
||||||
clearTimeout(timer);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
// Never wait on the client here: a stalled or hostile body may never confirm the cancel.
|
|
||||||
void reader.cancel().catch(() => undefined);
|
|
||||||
reader.releaseLock();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export type ArtifactHttpDeps = {
|
|
||||||
prisma: PrismaClient;
|
|
||||||
artifacts: ArtifactStore;
|
|
||||||
/** The signed-in user (cookie or bearer session), or null. */
|
|
||||||
sessionUserId: (request: Request) => Promise<string | null>;
|
|
||||||
/** Same rule as CORS. Raw uploads can be CORS "simple" requests, so the origin is checked. */
|
|
||||||
trustedOrigin: (origin: string) => boolean;
|
|
||||||
/** Overrides ARTIFACT_UPLOAD_STALL_TIMEOUT_MS (tests). */
|
|
||||||
uploadStallTimeoutMs?: number;
|
|
||||||
};
|
|
||||||
|
|
||||||
/** Generated ids only; anything else is not an artifact. */
|
|
||||||
const ARTIFACT_ID = /^[A-Za-z0-9_-]{1,64}$/;
|
|
||||||
const MAX_RANGE_DIGITS = 15;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Parses a single `bytes=` range against the stored size. Returns null when the header should
|
|
||||||
* be ignored (absent or another unit) and "unsatisfiable" for multiple ranges or offsets
|
|
||||||
* outside the file, which the caller answers with 416.
|
|
||||||
*/
|
|
||||||
export function parseByteRange(
|
|
||||||
header: string | undefined,
|
|
||||||
size: number,
|
|
||||||
): ArtifactByteRange | "unsatisfiable" | null {
|
|
||||||
if (!header) return null;
|
|
||||||
const value = header.trim();
|
|
||||||
if (!value.toLowerCase().startsWith("bytes=")) return null;
|
|
||||||
const spec = value.slice("bytes=".length).trim();
|
|
||||||
const match = new RegExp(`^(\\d{0,${MAX_RANGE_DIGITS}})-(\\d{0,${MAX_RANGE_DIGITS}})$`).exec(
|
|
||||||
spec,
|
|
||||||
);
|
|
||||||
if (!match || size <= 0) return "unsatisfiable";
|
|
||||||
const [, first = "", last = ""] = match;
|
|
||||||
if (first === "" && last === "") return "unsatisfiable";
|
|
||||||
if (first === "") {
|
|
||||||
const suffix = Number(last);
|
|
||||||
if (suffix <= 0) return "unsatisfiable";
|
|
||||||
return { start: Math.max(0, size - suffix), end: size - 1 };
|
|
||||||
}
|
|
||||||
const start = Number(first);
|
|
||||||
const end = last === "" ? size - 1 : Number(last);
|
|
||||||
if (start >= size || end < start) return "unsatisfiable";
|
|
||||||
return { start, end: Math.min(end, size - 1) };
|
|
||||||
}
|
|
||||||
|
|
||||||
/** `inline` or `attachment` with an ASCII fallback and the exact UTF-8 name (RFC 6266). */
|
|
||||||
export function contentDisposition(name: string, inline: boolean): string {
|
|
||||||
const cleaned =
|
|
||||||
Array.from(name, (char) => {
|
|
||||||
const code = char.codePointAt(0) ?? 0;
|
|
||||||
return code < 0x20 || code === 0x7f || char === "/" || char === "\\" ? "_" : char;
|
|
||||||
})
|
|
||||||
.join("")
|
|
||||||
.trim() || "download";
|
|
||||||
const ascii = cleaned.replace(/[^ -~]/g, "_").replace(/["%;]/g, "_");
|
|
||||||
const encoded = encodeURIComponent(cleaned).replace(
|
|
||||||
/['()*]/g,
|
|
||||||
(char) => `%${char.charCodeAt(0).toString(16).toUpperCase()}`,
|
|
||||||
);
|
|
||||||
return `${inline ? "inline" : "attachment"}; filename="${ascii}"; filename*=UTF-8''${encoded}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function adapterContext(actor: Actor, operationId: string, signal: AbortSignal): AdapterContext {
|
|
||||||
return {
|
|
||||||
operationId,
|
|
||||||
traceId: operationId,
|
|
||||||
spaceId: actor.spaceId,
|
|
||||||
userId: actor.userId,
|
|
||||||
signal,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function notFound(c: Context) {
|
|
||||||
return c.json({ error: "Not found" }, 404);
|
|
||||||
}
|
|
||||||
|
|
||||||
function artifactDto(row: {
|
|
||||||
id: string;
|
|
||||||
botId: string | null;
|
|
||||||
groupId: string | null;
|
|
||||||
runId: string | null;
|
|
||||||
name: string;
|
|
||||||
mimeType: string;
|
|
||||||
size: number;
|
|
||||||
createdAt: Date;
|
|
||||||
}) {
|
|
||||||
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(),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Streams artifacts over HTTP so media can play with seeking and large files never travel as
|
|
||||||
* base64 RPC payloads. Authorization matches `artifacts.get` and `artifacts.create`.
|
|
||||||
*/
|
|
||||||
export function mountArtifactHttpRoutes(app: Hono, deps: ArtifactHttpDeps) {
|
|
||||||
const repos = createRepos(deps.prisma);
|
|
||||||
const groupRepos = createGroupRepos(deps.prisma);
|
|
||||||
|
|
||||||
/** Same rule as artifacts.get: the owner's artifact of an active bot thread or group. */
|
|
||||||
async function readableArtifact(
|
|
||||||
userId: string,
|
|
||||||
requestedSpaceId: string | undefined,
|
|
||||||
id: string,
|
|
||||||
) {
|
|
||||||
if (!ARTIFACT_ID.test(id)) return null;
|
|
||||||
const row = await deps.prisma.artifact.findUnique({ where: { id } });
|
|
||||||
if (!row) return null;
|
|
||||||
// <video> and <img> cannot send the space header, so the artifact's space is the default;
|
|
||||||
// membership is still required and an explicit header must name the same space.
|
|
||||||
const actor = await requireMembership(
|
|
||||||
deps.prisma,
|
|
||||||
userId,
|
|
||||||
requestedSpaceId ?? row.spaceId,
|
|
||||||
).catch(() => null);
|
|
||||||
if (!actor || actor.spaceId !== row.spaceId || actor.userId !== row.userId) return null;
|
|
||||||
try {
|
|
||||||
if (row.groupId) await groupRepos.getGroupTarget(actor, row.groupId);
|
|
||||||
else if (row.botId) await repos.getBot(actor, row.botId);
|
|
||||||
else return null;
|
|
||||||
} catch (error) {
|
|
||||||
if (error instanceof IsolationError) return null;
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
return { row, actor };
|
|
||||||
}
|
|
||||||
|
|
||||||
app.get(`${ARTIFACT_HTTP_PATH}/:id`, async (c) => {
|
|
||||||
const userId = await deps.sessionUserId(c.req.raw);
|
|
||||||
if (!userId) return c.json({ error: "Unauthorized" }, 401);
|
|
||||||
const found = await readableArtifact(
|
|
||||||
userId,
|
|
||||||
c.req.header("x-rakazo-space-id"),
|
|
||||||
c.req.param("id"),
|
|
||||||
);
|
|
||||||
if (!found) return notFound(c);
|
|
||||||
const { row, actor } = found;
|
|
||||||
const context = adapterContext(actor, `artifact-http-get:${row.id}`, c.req.raw.signal);
|
|
||||||
let size: number;
|
|
||||||
try {
|
|
||||||
({ size } = await deps.artifacts.stat(row.storageKey, context));
|
|
||||||
} catch {
|
|
||||||
return notFound(c);
|
|
||||||
}
|
|
||||||
|
|
||||||
const mimeType = isAllowedAttachmentMimeType(row.mimeType)
|
|
||||||
? row.mimeType
|
|
||||||
: "application/octet-stream";
|
|
||||||
const inline = attachmentKindOf(mimeType) !== "file" && c.req.query("download") === undefined;
|
|
||||||
const etag = /^[A-Za-z0-9]{16,128}$/.test(row.hash) ? `"${row.hash}"` : undefined;
|
|
||||||
const headers = new Headers({
|
|
||||||
"accept-ranges": "bytes",
|
|
||||||
// Revalidate every time so a revoked thread stops serving from the browser cache.
|
|
||||||
"cache-control": "private, no-cache",
|
|
||||||
"content-disposition": contentDisposition(row.name, inline),
|
|
||||||
"content-type": mimeType,
|
|
||||||
"cross-origin-resource-policy": "same-origin",
|
|
||||||
"x-content-type-options": "nosniff",
|
|
||||||
});
|
|
||||||
if (etag) headers.set("etag", etag);
|
|
||||||
if (etag && c.req.header("if-none-match") === etag) {
|
|
||||||
return new Response(null, { status: 304, headers });
|
|
||||||
}
|
|
||||||
|
|
||||||
const ifRange = c.req.header("if-range");
|
|
||||||
const range =
|
|
||||||
ifRange === undefined || ifRange === etag
|
|
||||||
? parseByteRange(c.req.header("range"), size)
|
|
||||||
: null;
|
|
||||||
if (range === "unsatisfiable") {
|
|
||||||
headers.set("content-range", `bytes */${size}`);
|
|
||||||
return new Response(null, { status: 416, headers });
|
|
||||||
}
|
|
||||||
const status = range ? 206 : 200;
|
|
||||||
headers.set("content-length", String(range ? range.end - range.start + 1 : size));
|
|
||||||
if (range) headers.set("content-range", `bytes ${range.start}-${range.end}/${size}`);
|
|
||||||
// Hono answers HEAD from this handler and drops the body; never open a stream for it.
|
|
||||||
if (c.req.method === "HEAD") return new Response(null, { status, headers });
|
|
||||||
|
|
||||||
let body: ReadableStream<Uint8Array>;
|
|
||||||
try {
|
|
||||||
body = await deps.artifacts.createReadStream(row.storageKey, context, range ?? undefined);
|
|
||||||
} catch {
|
|
||||||
return notFound(c);
|
|
||||||
}
|
|
||||||
return new Response(body, { status, headers });
|
|
||||||
});
|
|
||||||
|
|
||||||
app.post(ARTIFACT_HTTP_PATH, async (c) => {
|
|
||||||
const request = c.req.raw;
|
|
||||||
const reject = (status: 400 | 401 | 403 | 404 | 408 | 413 | 415, error: string) => {
|
|
||||||
cancelBody(request.body);
|
|
||||||
return c.json({ error }, status);
|
|
||||||
};
|
|
||||||
const origin = c.req.header("origin");
|
|
||||||
if (origin && !deps.trustedOrigin(origin)) return reject(403, "Forbidden");
|
|
||||||
const userId = await deps.sessionUserId(request);
|
|
||||||
if (!userId) return reject(401, "Unauthorized");
|
|
||||||
const actor = await requireMembership(
|
|
||||||
deps.prisma,
|
|
||||||
userId,
|
|
||||||
c.req.header("x-rakazo-space-id"),
|
|
||||||
).catch(() => null);
|
|
||||||
if (!actor) return reject(401, "Unauthorized");
|
|
||||||
|
|
||||||
const botId = c.req.query("botId") || undefined;
|
|
||||||
const groupId = c.req.query("groupId") || undefined;
|
|
||||||
const name = c.req.query("name") ?? "";
|
|
||||||
if (Boolean(botId) === Boolean(groupId)) {
|
|
||||||
return reject(400, "Provide exactly one of botId or groupId");
|
|
||||||
}
|
|
||||||
if (name.length < 1 || name.length > 255) return reject(400, "Invalid file name");
|
|
||||||
const mimeType = (c.req.header("content-type") ?? "").split(";", 1)[0]!.trim().toLowerCase();
|
|
||||||
if (!isAllowedAttachmentMimeType(mimeType)) {
|
|
||||||
return reject(415, "Unsupported attachment type");
|
|
||||||
}
|
|
||||||
const maxBytes = artifactMaxBytes(mimeType);
|
|
||||||
const declared = c.req.header("content-length");
|
|
||||||
if (declared !== undefined) {
|
|
||||||
if (!/^\d{1,16}$/.test(declared)) return reject(400, "Invalid content length");
|
|
||||||
if (Number(declared) > maxBytes) return reject(413, "File is too large.");
|
|
||||||
if (Number(declared) === 0) return reject(400, "File is empty.");
|
|
||||||
}
|
|
||||||
if (!request.body) return reject(400, "File is empty.");
|
|
||||||
|
|
||||||
// Same targets as artifacts.create: an own active bot, or an active group of own bots.
|
|
||||||
let ownerBotId: string | undefined;
|
|
||||||
try {
|
|
||||||
ownerBotId = botId
|
|
||||||
? (await repos.getBot(actor, botId)).id
|
|
||||||
: (await groupRepos.getGroupTarget(actor, groupId!)).members[0]?.bot.id;
|
|
||||||
} catch (error) {
|
|
||||||
if (!(error instanceof IsolationError)) throw error;
|
|
||||||
}
|
|
||||||
if (!ownerBotId) return reject(404, "Not found");
|
|
||||||
|
|
||||||
let row: Awaited<ReturnType<typeof storeArtifactStream>>;
|
|
||||||
try {
|
|
||||||
row = await storeArtifactStream(deps, {
|
|
||||||
spaceId: actor.spaceId,
|
|
||||||
userId: actor.userId,
|
|
||||||
botId: ownerBotId,
|
|
||||||
groupId: groupId ?? null,
|
|
||||||
runId: null,
|
|
||||||
name,
|
|
||||||
mimeType,
|
|
||||||
stream: stallGuardedChunks(
|
|
||||||
request.body,
|
|
||||||
deps.uploadStallTimeoutMs ?? ARTIFACT_UPLOAD_STALL_TIMEOUT_MS,
|
|
||||||
),
|
|
||||||
maxBytes,
|
|
||||||
signal: request.signal,
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
if (error instanceof ArtifactTooLargeError) return reject(413, "File is too large.");
|
|
||||||
if (error instanceof UploadStalledError) return reject(408, "Upload stalled.");
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
if (row.size === 0) {
|
|
||||||
await deps.prisma.artifact.delete({ where: { id: row.id } });
|
|
||||||
await deps.artifacts
|
|
||||||
.remove(
|
|
||||||
row.storageKey,
|
|
||||||
adapterContext(actor, `artifact-http-post:${row.id}`, request.signal),
|
|
||||||
)
|
|
||||||
.catch(() => undefined);
|
|
||||||
return c.json({ error: "File is empty." }, 400);
|
|
||||||
}
|
|
||||||
return c.json(artifactDto(row), 201);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@ -11,12 +11,6 @@ import {
|
|||||||
} from "@rakazo/core";
|
} from "@rakazo/core";
|
||||||
import { IsolationError, type PrismaClient } from "@rakazo/db";
|
import { IsolationError, type PrismaClient } from "@rakazo/db";
|
||||||
|
|
||||||
/**
|
|
||||||
* Base64 reads load the whole file into memory; larger media is served by
|
|
||||||
* GET /api/artifacts/:id instead.
|
|
||||||
*/
|
|
||||||
export const ARTIFACT_INLINE_READ_MAX_BYTES = 25 * 1024 * 1024;
|
|
||||||
|
|
||||||
function adapterContext(actor: Actor, botId: string, operationId: string) {
|
function adapterContext(actor: Actor, botId: string, operationId: string) {
|
||||||
return {
|
return {
|
||||||
operationId,
|
operationId,
|
||||||
@ -137,9 +131,6 @@ async function readArtifact(
|
|||||||
},
|
},
|
||||||
contextBotId: string,
|
contextBotId: string,
|
||||||
) {
|
) {
|
||||||
if (row.size > ARTIFACT_INLINE_READ_MAX_BYTES) {
|
|
||||||
throw new AttachmentValidationError("This file is too large to load inline.");
|
|
||||||
}
|
|
||||||
const bytes = await artifacts.get(
|
const bytes = await artifacts.get(
|
||||||
row.storageKey,
|
row.storageKey,
|
||||||
adapterContext(actor, contextBotId, `artifact-get:${row.id}`),
|
adapterContext(actor, contextBotId, `artifact-get:${row.id}`),
|
||||||
|
|||||||
@ -1,74 +0,0 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
|
||||||
import { importableRoutines, relativeImportedFiles, safeImportPath } from "./bot-import.js";
|
|
||||||
|
|
||||||
describe("safeImportPath", () => {
|
|
||||||
it("normalizes separators and dot segments", () => {
|
|
||||||
expect(safeImportPath("notes\\./result.txt")).toBe("notes/result.txt");
|
|
||||||
expect(safeImportPath("MEMORY.md")).toBe("MEMORY.md");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("rejects paths that could leave the bot home", () => {
|
|
||||||
expect(safeImportPath("../secrets")).toBeNull();
|
|
||||||
expect(safeImportPath("notes/../../x")).toBeNull();
|
|
||||||
expect(safeImportPath("/etc/passwd")).toBeNull();
|
|
||||||
expect(safeImportPath("C:\\Windows")).toBeNull();
|
|
||||||
expect(safeImportPath("./")).toBeNull();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("relativeImportedFiles", () => {
|
|
||||||
it("drops the old Team Computer workspace prefix", () => {
|
|
||||||
expect(
|
|
||||||
relativeImportedFiles([
|
|
||||||
{ path: "bots/old-id/notes/result.txt", content: "a" },
|
|
||||||
{ path: "bots/old-id/README.md", content: "b" },
|
|
||||||
]),
|
|
||||||
).toEqual([
|
|
||||||
{ path: "notes/result.txt", content: "a" },
|
|
||||||
{ path: "README.md", content: "b" },
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("keeps Private Computer paths as they are", () => {
|
|
||||||
const files = [
|
|
||||||
{ path: "notes/result.txt", content: "a" },
|
|
||||||
{ path: "bots/other/file.txt", content: "b" },
|
|
||||||
];
|
|
||||||
expect(relativeImportedFiles(files)).toEqual(files);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("keeps paths when files span several workspaces", () => {
|
|
||||||
const files = [
|
|
||||||
{ path: "bots/a/x.txt", content: "a" },
|
|
||||||
{ path: "bots/b/y.txt", content: "b" },
|
|
||||||
];
|
|
||||||
expect(relativeImportedFiles(files)).toEqual(files);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("refuses the whole set when one path is unsafe", () => {
|
|
||||||
expect(
|
|
||||||
relativeImportedFiles([
|
|
||||||
{ path: "ok.txt", content: "a" },
|
|
||||||
{ path: "../escape.txt", content: "b" },
|
|
||||||
]),
|
|
||||||
).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("accepts an export without files", () => {
|
|
||||||
expect(relativeImportedFiles([])).toEqual([]);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("importableRoutines", () => {
|
|
||||||
it("leaves out one-shot schedules", () => {
|
|
||||||
const routines = [
|
|
||||||
{ name: "daily", crons: ["0 9 * * *"] },
|
|
||||||
{ name: "once", crons: ["@once"] },
|
|
||||||
{ name: "webhook", crons: [] },
|
|
||||||
];
|
|
||||||
expect(importableRoutines(routines).map((routine) => routine.name)).toEqual([
|
|
||||||
"daily",
|
|
||||||
"webhook",
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@ -1,34 +0,0 @@
|
|||||||
import { isOneShotRoutineCrons } from "@rakazo/core";
|
|
||||||
|
|
||||||
/** A relative, forward-slash path without `.`/`..` segments, or null when it could escape. */
|
|
||||||
export function safeImportPath(raw: string): string | null {
|
|
||||||
const normalized = raw.replace(/\\/g, "/");
|
|
||||||
if (normalized.startsWith("/") || /^[a-z]:/i.test(normalized)) return null;
|
|
||||||
const segments = normalized.split("/").filter((segment) => segment !== "" && segment !== ".");
|
|
||||||
if (segments.length === 0 || segments.includes("..")) return null;
|
|
||||||
return segments.join("/");
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Exports of bots on a Team Computer carry their workspace prefix (`bots/<old id>/…`).
|
|
||||||
* The prefix names the old bot, so it is dropped and the importer writes under the new one.
|
|
||||||
*/
|
|
||||||
export function relativeImportedFiles<T extends { path: string }>(
|
|
||||||
files: T[],
|
|
||||||
): Array<T & { path: string }> | null {
|
|
||||||
const safe: Array<T & { path: string }> = [];
|
|
||||||
for (const file of files) {
|
|
||||||
const path = safeImportPath(file.path);
|
|
||||||
if (!path) return null;
|
|
||||||
safe.push({ ...file, path });
|
|
||||||
}
|
|
||||||
const first = safe[0]?.path.split("/");
|
|
||||||
const workspace = first && first.length > 2 && first[0] === "bots" ? `bots/${first[1]}/` : null;
|
|
||||||
if (!workspace || !safe.every((file) => file.path.startsWith(workspace))) return safe;
|
|
||||||
return safe.map((file) => ({ ...file, path: file.path.slice(workspace.length) }));
|
|
||||||
}
|
|
||||||
|
|
||||||
/** One-shot schedules belong to the chat that set them, so they are not carried over. */
|
|
||||||
export function importableRoutines<T extends { crons: string[] }>(routines: T[]): T[] {
|
|
||||||
return routines.filter((routine) => !isOneShotRoutineCrons(routine.crons));
|
|
||||||
}
|
|
||||||
@ -1,96 +0,0 @@
|
|||||||
import { ORPCError } from "@orpc/server";
|
|
||||||
import type { JobPublisher } from "@rakazo/adapter-kit";
|
|
||||||
import { getCoachRhythm, setCoachRhythm } from "@rakazo/adapters";
|
|
||||||
import type {
|
|
||||||
Actor,
|
|
||||||
CheckIn,
|
|
||||||
CoachRhythm,
|
|
||||||
CoachToday,
|
|
||||||
Goal,
|
|
||||||
Habit,
|
|
||||||
LogCheckIn,
|
|
||||||
} from "@rakazo/contracts";
|
|
||||||
import type { CheckInFilter, GoalPatch, HabitPatch, PrismaClient } from "@rakazo/db";
|
|
||||||
import {
|
|
||||||
CoachNotFoundError,
|
|
||||||
createGoal,
|
|
||||||
createHabit,
|
|
||||||
InvalidCoachInputError,
|
|
||||||
listCheckIns,
|
|
||||||
listGoals,
|
|
||||||
listHabits,
|
|
||||||
loadCoachToday,
|
|
||||||
logCheckIn,
|
|
||||||
removeGoal,
|
|
||||||
removeHabit,
|
|
||||||
updateGoal,
|
|
||||||
updateHabit,
|
|
||||||
} from "@rakazo/db";
|
|
||||||
|
|
||||||
export interface CoachDeps {
|
|
||||||
prisma: PrismaClient;
|
|
||||||
jobs: JobPublisher;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Another user's ids read as missing, so a caller cannot probe them. */
|
|
||||||
async function mapErrors<T>(work: () => Promise<T>): Promise<T> {
|
|
||||||
try {
|
|
||||||
return await work();
|
|
||||||
} catch (error) {
|
|
||||||
if (error instanceof CoachNotFoundError) {
|
|
||||||
throw new ORPCError("NOT_FOUND", { message: error.message });
|
|
||||||
}
|
|
||||||
if (error instanceof InvalidCoachInputError) {
|
|
||||||
throw new ORPCError("BAD_REQUEST", { message: error.message });
|
|
||||||
}
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Coach procedures for the signed-in user only; nothing here is space-scoped. */
|
|
||||||
export function createCoachService(deps: CoachDeps) {
|
|
||||||
const { prisma } = deps;
|
|
||||||
return {
|
|
||||||
today: (actor: Actor): Promise<CoachToday> => loadCoachToday(prisma, actor.userId),
|
|
||||||
|
|
||||||
listGoals: (actor: Actor, input: { status?: Goal["status"] }) =>
|
|
||||||
listGoals(prisma, actor.userId, input),
|
|
||||||
createGoal: (
|
|
||||||
actor: Actor,
|
|
||||||
input: {
|
|
||||||
title: string;
|
|
||||||
detail?: string;
|
|
||||||
targetDate?: string | null;
|
|
||||||
spaceId?: string | null;
|
|
||||||
},
|
|
||||||
) => mapErrors(() => createGoal(prisma, actor.userId, { ...input, source: "user" })),
|
|
||||||
updateGoal: (actor: Actor, { goalId, ...patch }: GoalPatch & { goalId: string }) =>
|
|
||||||
mapErrors(() => updateGoal(prisma, actor.userId, goalId, patch)),
|
|
||||||
removeGoal: async (actor: Actor, input: { goalId: string }) => {
|
|
||||||
await mapErrors(() => removeGoal(prisma, actor.userId, input.goalId));
|
|
||||||
return { ok: true as const };
|
|
||||||
},
|
|
||||||
|
|
||||||
listHabits: (actor: Actor, input: { includeInactive?: boolean }): Promise<Habit[]> =>
|
|
||||||
listHabits(prisma, actor.userId, input),
|
|
||||||
createHabit: (
|
|
||||||
actor: Actor,
|
|
||||||
input: { title: string; cadence: Habit["cadence"]; goalId?: string | null },
|
|
||||||
) => mapErrors(() => createHabit(prisma, actor.userId, { ...input, source: "user" })),
|
|
||||||
updateHabit: (actor: Actor, { habitId, ...patch }: HabitPatch & { habitId: string }) =>
|
|
||||||
mapErrors(() => updateHabit(prisma, actor.userId, habitId, patch)),
|
|
||||||
removeHabit: async (actor: Actor, input: { habitId: string }) => {
|
|
||||||
await mapErrors(() => removeHabit(prisma, actor.userId, input.habitId));
|
|
||||||
return { ok: true as const };
|
|
||||||
},
|
|
||||||
|
|
||||||
logCheckIn: (actor: Actor, input: LogCheckIn): Promise<CheckIn> =>
|
|
||||||
mapErrors(() => logCheckIn(prisma, actor.userId, input)),
|
|
||||||
listCheckIns: (actor: Actor, input: CheckInFilter) =>
|
|
||||||
mapErrors(() => listCheckIns(prisma, actor.userId, input)),
|
|
||||||
|
|
||||||
getRhythm: (actor: Actor): Promise<CoachRhythm> => getCoachRhythm(prisma, actor.userId),
|
|
||||||
setRhythm: (actor: Actor, rhythm: CoachRhythm): Promise<CoachRhythm> =>
|
|
||||||
setCoachRhythm(deps, { userId: actor.userId, rhythm }),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@ -1,50 +0,0 @@
|
|||||||
import { FEATURED_CONNECTOR_LABELS, matchFeaturedConnectorId } from "@rakazo/core";
|
|
||||||
import type { PrismaClient } from "@rakazo/db";
|
|
||||||
import { updateUserProfile } from "@rakazo/db";
|
|
||||||
import { getLogger } from "@rakazo/logging";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The app's own name: a featured app's label, otherwise the label of the user's first account for
|
|
||||||
* it (later accounts may be named "Gmail 2" or renamed).
|
|
||||||
*/
|
|
||||||
async function connectedAppName(
|
|
||||||
prisma: PrismaClient,
|
|
||||||
connection: { userId: string; connectorId: string; provider: string },
|
|
||||||
): Promise<string | null> {
|
|
||||||
const featured = matchFeaturedConnectorId(connection.provider);
|
|
||||||
if (featured) return FEATURED_CONNECTOR_LABELS[featured];
|
|
||||||
const first = await prisma.connection.findFirst({
|
|
||||||
where: {
|
|
||||||
userId: connection.userId,
|
|
||||||
connectorId: connection.connectorId,
|
|
||||||
provider: connection.provider,
|
|
||||||
},
|
|
||||||
orderBy: [{ createdAt: "asc" }, { id: "asc" }],
|
|
||||||
select: { displayName: true },
|
|
||||||
});
|
|
||||||
const name = first?.displayName.trim().slice(0, 80);
|
|
||||||
return name || null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Remembers "Uses <App>" in the user's profile when one of their connections becomes connected.
|
|
||||||
* Revoking the connection keeps the fact; the user removes it in About you. Best effort: the
|
|
||||||
* connection itself has already succeeded, so a full profile or a database error only logs.
|
|
||||||
*/
|
|
||||||
export async function recordConnectedApp(
|
|
||||||
prisma: PrismaClient,
|
|
||||||
connection: { userId: string; connectorId: string; provider: string },
|
|
||||||
): Promise<void> {
|
|
||||||
try {
|
|
||||||
const name = await connectedAppName(prisma, connection);
|
|
||||||
if (!name) return;
|
|
||||||
await updateUserProfile(prisma, connection.userId, {
|
|
||||||
addFacts: [{ category: "tools", value: `Uses ${name}`, source: "connector" }],
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
getLogger().error("connected app fact was not recorded", error, {
|
|
||||||
connectorId: connection.connectorId,
|
|
||||||
provider: connection.provider,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -42,6 +42,16 @@ describe("loadEnv", () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("loads a trading bot origin and refuses credentials", () => {
|
||||||
|
expect(loadEnv(base).tradingBotUrl).toBeUndefined();
|
||||||
|
expect(
|
||||||
|
loadEnv({ ...base, TRADING_BOT_URL: " http://127.0.0.1:8000/bot/ " }).tradingBotUrl,
|
||||||
|
).toBe("http://127.0.0.1:8000");
|
||||||
|
expect(() =>
|
||||||
|
loadEnv({ ...base, TRADING_BOT_URL: "http://user:secret@127.0.0.1:8000" }),
|
||||||
|
).toThrow(/^Invalid TRADING_BOT_URL$/);
|
||||||
|
});
|
||||||
|
|
||||||
it("loads an optional integrations catalog mirror", () => {
|
it("loads an optional integrations catalog mirror", () => {
|
||||||
expect(loadEnv(base).integrationsCatalogUrl).toBeUndefined();
|
expect(loadEnv(base).integrationsCatalogUrl).toBeUndefined();
|
||||||
expect(
|
expect(
|
||||||
|
|||||||
@ -8,6 +8,7 @@ import {
|
|||||||
resolveEncryptionKey,
|
resolveEncryptionKey,
|
||||||
resolveScreenProxySecret,
|
resolveScreenProxySecret,
|
||||||
resolveSupervisorToken,
|
resolveSupervisorToken,
|
||||||
|
resolveTradingBotOrigin,
|
||||||
} from "@rakazo/core";
|
} from "@rakazo/core";
|
||||||
|
|
||||||
export { resolveCloudAgentProvider, resolveSandboxProvider } from "@rakazo/adapters";
|
export { resolveCloudAgentProvider, resolveSandboxProvider } from "@rakazo/adapters";
|
||||||
@ -92,6 +93,8 @@ export interface AppEnv {
|
|||||||
updaterToken: string | undefined;
|
updaterToken: string | undefined;
|
||||||
/** Current application image tag; used for compose manual-upgrade command selection. */
|
/** Current application image tag; used for compose manual-upgrade command selection. */
|
||||||
imageTag: string | undefined;
|
imageTag: string | undefined;
|
||||||
|
/** Tradingbot origin for the same-origin status proxy. Unset on SaaS builds. */
|
||||||
|
tradingBotUrl: string | undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function loadEnv(source: NodeJS.ProcessEnv = process.env): AppEnv {
|
export function loadEnv(source: NodeJS.ProcessEnv = process.env): AppEnv {
|
||||||
@ -179,6 +182,7 @@ export function loadEnv(source: NodeJS.ProcessEnv = process.env): AppEnv {
|
|||||||
updaterUrl,
|
updaterUrl,
|
||||||
updaterToken,
|
updaterToken,
|
||||||
imageTag: optional(source.RAKAZO_IMAGE_TAG),
|
imageTag: optional(source.RAKAZO_IMAGE_TAG),
|
||||||
|
tradingBotUrl: resolveTradingBotOrigin(source.TRADING_BOT_URL),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -18,11 +18,9 @@ function run(
|
|||||||
status: "waiting_input" | "waiting_takeover",
|
status: "waiting_input" | "waiting_takeover",
|
||||||
thread: Partial<InboxRunRow["thread"]> = {},
|
thread: Partial<InboxRunRow["thread"]> = {},
|
||||||
bot = scout,
|
bot = scout,
|
||||||
spaceId = "space-home",
|
|
||||||
): InboxRunRow {
|
): InboxRunRow {
|
||||||
return {
|
return {
|
||||||
id,
|
id,
|
||||||
spaceId,
|
|
||||||
status,
|
status,
|
||||||
updatedAt: at(0),
|
updatedAt: at(0),
|
||||||
bot,
|
bot,
|
||||||
@ -50,10 +48,8 @@ const approval = {
|
|||||||
function unreadThread(
|
function unreadThread(
|
||||||
bot: InboxUnreadThreadRow["bot"],
|
bot: InboxUnreadThreadRow["bot"],
|
||||||
latest?: { id: string; text: string; minute: number },
|
latest?: { id: string; text: string; minute: number },
|
||||||
spaceId = "space-home",
|
|
||||||
): InboxUnreadThreadRow {
|
): InboxUnreadThreadRow {
|
||||||
return {
|
return {
|
||||||
spaceId,
|
|
||||||
createdAt: at(0),
|
createdAt: at(0),
|
||||||
bot,
|
bot,
|
||||||
messages: latest
|
messages: latest
|
||||||
@ -231,64 +227,4 @@ describe("buildInboxItems", () => {
|
|||||||
});
|
});
|
||||||
expect(items[2]).toMatchObject({ messageId: null, text: "", createdAt: at(0).toISOString() });
|
expect(items[2]).toMatchObject({ messageId: null, text: "", createdAt: at(0).toISOString() });
|
||||||
});
|
});
|
||||||
|
|
||||||
it("lists asks from another space's main conversation, but not the open space's own", () => {
|
|
||||||
const project = { id: "space-launch", name: "Launch" };
|
|
||||||
const main = (groupId: string) => ({
|
|
||||||
botId: null,
|
|
||||||
groupId,
|
|
||||||
group: { kind: "main", archivedAt: null },
|
|
||||||
});
|
|
||||||
const items = buildInboxItems({
|
|
||||||
runs: [
|
|
||||||
run("home-main", "waiting_input", main("group-main-home")),
|
|
||||||
run("project-main", "waiting_input", main("group-main-launch"), writer, project.id),
|
|
||||||
],
|
|
||||||
messages: [
|
|
||||||
message("ask-home", "home-main", [question], 1),
|
|
||||||
message("ask-project", "project-main", [question], 2),
|
|
||||||
],
|
|
||||||
unreadThreads: [],
|
|
||||||
otherSpaces: new Map([[project.id, project]]),
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(items).toHaveLength(1);
|
|
||||||
expect(items[0]).toMatchObject({
|
|
||||||
runId: "project-main",
|
|
||||||
space: project,
|
|
||||||
target: { groupId: "group-main-launch" },
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("names the space of items from the user's other spaces", () => {
|
|
||||||
const side = { id: "space-side", name: "Side project" };
|
|
||||||
const items = buildInboxItems({
|
|
||||||
runs: [
|
|
||||||
run("run-home", "waiting_input"),
|
|
||||||
run("run-side", "waiting_input", {}, writer, side.id),
|
|
||||||
run("takeover-side", "waiting_takeover", {}, writer, side.id),
|
|
||||||
],
|
|
||||||
messages: [
|
|
||||||
message("ask-home", "run-home", [question], 1),
|
|
||||||
message("ask-side", "run-side", [question], 2),
|
|
||||||
],
|
|
||||||
unreadThreads: [
|
|
||||||
unreadThread(
|
|
||||||
{ id: "bot-quiet", name: "Quiet", color: "#3EC5A8" },
|
|
||||||
{ id: "quiet-latest", text: "Done", minute: 3 },
|
|
||||||
side.id,
|
|
||||||
),
|
|
||||||
],
|
|
||||||
otherSpaces: new Map([[side.id, side]]),
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(items.map((item) => [item.kind, item.space?.name ?? null])).toEqual([
|
|
||||||
["takeover", "Side project"],
|
|
||||||
["ask", null],
|
|
||||||
["ask", "Side project"],
|
|
||||||
["unread", "Side project"],
|
|
||||||
]);
|
|
||||||
expect(items[1]).not.toHaveProperty("space");
|
|
||||||
expect(items[2]).toMatchObject({ space: side, target: { botId: writer.id } });
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|||||||
@ -4,7 +4,7 @@ import { isApprovalAskBlock } from "@rakazo/core";
|
|||||||
import type { PrismaClient } from "@rakazo/db";
|
import type { PrismaClient } from "@rakazo/db";
|
||||||
import { promptSnippet } from "./runs.js";
|
import { promptSnippet } from "./runs.js";
|
||||||
|
|
||||||
/** Caps per query; the user's spaces rarely hold more waiting runs or unread threads than this. */
|
/** Caps per query; a space rarely holds more waiting runs or unread threads than this. */
|
||||||
const INBOX_RUN_LIMIT = 50;
|
const INBOX_RUN_LIMIT = 50;
|
||||||
const INBOX_UNREAD_LIMIT = 50;
|
const INBOX_UNREAD_LIMIT = 50;
|
||||||
const INBOX_PREVIEW_LENGTH = 280;
|
const INBOX_PREVIEW_LENGTH = 280;
|
||||||
@ -12,11 +12,9 @@ const INBOX_PREVIEW_LENGTH = 280;
|
|||||||
type AskBlock = Extract<MessageBlock, { kind: "ask" }>;
|
type AskBlock = Extract<MessageBlock, { kind: "ask" }>;
|
||||||
type ComputerBlock = Extract<MessageBlock, { kind: "computer" }>;
|
type ComputerBlock = Extract<MessageBlock, { kind: "computer" }>;
|
||||||
type InboxBot = { id: string; name: string; color: string };
|
type InboxBot = { id: string; name: string; color: string };
|
||||||
type InboxSpace = NonNullable<InboxItem["space"]>;
|
|
||||||
|
|
||||||
export interface InboxRunRow {
|
export interface InboxRunRow {
|
||||||
id: string;
|
id: string;
|
||||||
spaceId: string;
|
|
||||||
status: string;
|
status: string;
|
||||||
updatedAt: Date;
|
updatedAt: Date;
|
||||||
bot: InboxBot;
|
bot: InboxBot;
|
||||||
@ -35,7 +33,6 @@ export interface InboxMessageRow {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface InboxUnreadThreadRow {
|
export interface InboxUnreadThreadRow {
|
||||||
spaceId: string;
|
|
||||||
createdAt: Date;
|
createdAt: Date;
|
||||||
bot: InboxBot | null;
|
bot: InboxBot | null;
|
||||||
/** The newest bot message, if any. */
|
/** The newest bot message, if any. */
|
||||||
@ -76,14 +73,12 @@ function previewText(blocks: unknown): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Where an ask can be answered from. The open space's main conversation already shows its own
|
* Where an ask can be answered from. The main conversation already shows its own asks, and an
|
||||||
* asks (a project's main conversation does not), and an archived group or an external
|
* archived group or an external conversation has no thread target to answer through.
|
||||||
* conversation has no thread target to answer through.
|
|
||||||
*/
|
*/
|
||||||
function askTarget(thread: InboxRunRow["thread"], currentSpace: boolean): InboxThreadTarget | null {
|
function askTarget(thread: InboxRunRow["thread"]): InboxThreadTarget | null {
|
||||||
if (thread.groupId) {
|
if (thread.groupId) {
|
||||||
if (!thread.group || thread.group.archivedAt) return null;
|
if (!thread.group || thread.group.kind === "main" || thread.group.archivedAt) return null;
|
||||||
if (thread.group.kind === "main" && currentSpace) return null;
|
|
||||||
return { groupId: thread.groupId };
|
return { groupId: thread.groupId };
|
||||||
}
|
}
|
||||||
return thread.botId ? { botId: thread.botId } : null;
|
return thread.botId ? { botId: thread.botId } : null;
|
||||||
@ -100,20 +95,14 @@ function byCreatedAt(left: InboxItem, right: InboxItem): number {
|
|||||||
/**
|
/**
|
||||||
* Turns waiting runs, their ask and takeover messages, and unread threads into inbox items:
|
* Turns waiting runs, their ask and takeover messages, and unread threads into inbox items:
|
||||||
* everything that waits on the user first, then unread threads not already covered, each
|
* everything that waits on the user first, then unread threads not already covered, each
|
||||||
* oldest first. Items from another space name it.
|
* oldest first.
|
||||||
*/
|
*/
|
||||||
export function buildInboxItems(input: {
|
export function buildInboxItems(input: {
|
||||||
runs: InboxRunRow[];
|
runs: InboxRunRow[];
|
||||||
/** Ask and takeover messages of those runs, newest first. */
|
/** Ask and takeover messages of those runs, newest first. */
|
||||||
messages: InboxMessageRow[];
|
messages: InboxMessageRow[];
|
||||||
unreadThreads: InboxUnreadThreadRow[];
|
unreadThreads: InboxUnreadThreadRow[];
|
||||||
/** The user's other spaces by id; rows of any space not listed belong to the current one. */
|
|
||||||
otherSpaces?: ReadonlyMap<string, InboxSpace>;
|
|
||||||
}): InboxItem[] {
|
}): InboxItem[] {
|
||||||
const spaceOf = (spaceId: string) => {
|
|
||||||
const space = input.otherSpaces?.get(spaceId);
|
|
||||||
return space ? { space } : {};
|
|
||||||
};
|
|
||||||
const messagesByRun = new Map<string, InboxMessageRow[]>();
|
const messagesByRun = new Map<string, InboxMessageRow[]>();
|
||||||
for (const message of input.messages) {
|
for (const message of input.messages) {
|
||||||
if (!message.runId) continue;
|
if (!message.runId) continue;
|
||||||
@ -126,14 +115,13 @@ export function buildInboxItems(input: {
|
|||||||
for (const run of input.runs) {
|
for (const run of input.runs) {
|
||||||
const messages = messagesByRun.get(run.id) ?? [];
|
const messages = messagesByRun.get(run.id) ?? [];
|
||||||
if (run.status === "waiting_input") {
|
if (run.status === "waiting_input") {
|
||||||
const target = askTarget(run.thread, !input.otherSpaces?.has(run.spaceId));
|
const target = askTarget(run.thread);
|
||||||
const pending = target ? newest(messages, pendingAsk) : undefined;
|
const pending = target ? newest(messages, pendingAsk) : undefined;
|
||||||
if (!target || !pending) continue;
|
if (!target || !pending) continue;
|
||||||
waiting.push({
|
waiting.push({
|
||||||
kind: isApprovalAskBlock(pending.block) ? "approval" : "ask",
|
kind: isApprovalAskBlock(pending.block) ? "approval" : "ask",
|
||||||
bot: run.bot,
|
bot: run.bot,
|
||||||
target,
|
target,
|
||||||
...spaceOf(run.spaceId),
|
|
||||||
runId: run.id,
|
runId: run.id,
|
||||||
messageId: pending.message.id,
|
messageId: pending.message.id,
|
||||||
ask: pending.block,
|
ask: pending.block,
|
||||||
@ -147,7 +135,6 @@ export function buildInboxItems(input: {
|
|||||||
kind: "takeover",
|
kind: "takeover",
|
||||||
bot: run.bot,
|
bot: run.bot,
|
||||||
target: { botId: run.bot.id },
|
target: { botId: run.bot.id },
|
||||||
...spaceOf(run.spaceId),
|
|
||||||
runId: run.id,
|
runId: run.id,
|
||||||
messageId: request?.message.id ?? null,
|
messageId: request?.message.id ?? null,
|
||||||
text: request?.block.text ?? "",
|
text: request?.block.text ?? "",
|
||||||
@ -167,7 +154,6 @@ export function buildInboxItems(input: {
|
|||||||
kind: "unread",
|
kind: "unread",
|
||||||
bot: thread.bot,
|
bot: thread.bot,
|
||||||
target,
|
target,
|
||||||
...spaceOf(thread.spaceId),
|
|
||||||
messageId: latest?.id ?? null,
|
messageId: latest?.id ?? null,
|
||||||
text: previewText(latest?.blocks),
|
text: previewText(latest?.blocks),
|
||||||
createdAt: (latest?.createdAt ?? thread.createdAt).toISOString(),
|
createdAt: (latest?.createdAt ?? thread.createdAt).toISOString(),
|
||||||
@ -179,34 +165,13 @@ export function buildInboxItems(input: {
|
|||||||
|
|
||||||
const botSelect = { id: true, name: true, color: true } as const;
|
const botSelect = { id: true, name: true, color: true } as const;
|
||||||
|
|
||||||
/**
|
/** The current space's inbox: only the actor's own bots, runs and threads. */
|
||||||
* The other spaces whose items the inbox shows: every space the user belongs to, except spaces
|
|
||||||
* being deleted.
|
|
||||||
*/
|
|
||||||
async function otherInboxSpaces(
|
|
||||||
prisma: PrismaClient,
|
|
||||||
actor: Actor,
|
|
||||||
): Promise<Map<string, InboxSpace>> {
|
|
||||||
const memberships = await prisma.spaceMember.findMany({
|
|
||||||
where: { userId: actor.userId, spaceId: { not: actor.spaceId }, space: { deletingAt: null } },
|
|
||||||
select: { space: { select: { id: true, name: true } } },
|
|
||||||
});
|
|
||||||
return new Map(memberships.map(({ space }) => [space.id, space]));
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The inbox across the user's spaces: only the actor's own bots, runs and threads. Items are
|
|
||||||
* answered and opened in their own space, where the thread procedures check membership and
|
|
||||||
* ownership again.
|
|
||||||
*/
|
|
||||||
export async function listInbox(prisma: PrismaClient, actor: Actor): Promise<InboxItem[]> {
|
export async function listInbox(prisma: PrismaClient, actor: Actor): Promise<InboxItem[]> {
|
||||||
const otherSpaces = await otherInboxSpaces(prisma, actor);
|
|
||||||
const spaceIds = [actor.spaceId, ...otherSpaces.keys()];
|
|
||||||
const ownBot = { userId: actor.userId, archivedAt: null };
|
const ownBot = { userId: actor.userId, archivedAt: null };
|
||||||
const [runs, unreadThreads] = await Promise.all([
|
const [runs, unreadThreads] = await Promise.all([
|
||||||
prisma.run.findMany({
|
prisma.run.findMany({
|
||||||
where: {
|
where: {
|
||||||
spaceId: { in: spaceIds },
|
spaceId: actor.spaceId,
|
||||||
userId: actor.userId,
|
userId: actor.userId,
|
||||||
status: { in: ["waiting_input", "waiting_takeover"] },
|
status: { in: ["waiting_input", "waiting_takeover"] },
|
||||||
bot: ownBot,
|
bot: ownBot,
|
||||||
@ -214,7 +179,6 @@ export async function listInbox(prisma: PrismaClient, actor: Actor): Promise<Inb
|
|||||||
},
|
},
|
||||||
select: {
|
select: {
|
||||||
id: true,
|
id: true,
|
||||||
spaceId: true,
|
|
||||||
status: true,
|
status: true,
|
||||||
threadId: true,
|
threadId: true,
|
||||||
updatedAt: true,
|
updatedAt: true,
|
||||||
@ -233,13 +197,12 @@ export async function listInbox(prisma: PrismaClient, actor: Actor): Promise<Inb
|
|||||||
// Only specialists' own threads; group threads, the main conversation included, stay out.
|
// Only specialists' own threads; group threads, the main conversation included, stay out.
|
||||||
prisma.thread.findMany({
|
prisma.thread.findMany({
|
||||||
where: {
|
where: {
|
||||||
spaceId: { in: spaceIds },
|
spaceId: actor.spaceId,
|
||||||
userId: actor.userId,
|
userId: actor.userId,
|
||||||
unread: true,
|
unread: true,
|
||||||
bot: { ...ownBot, kind: "specialist" },
|
bot: { ...ownBot, kind: "specialist" },
|
||||||
},
|
},
|
||||||
select: {
|
select: {
|
||||||
spaceId: true,
|
|
||||||
createdAt: true,
|
createdAt: true,
|
||||||
bot: { select: botSelect },
|
bot: { select: botSelect },
|
||||||
messages: {
|
messages: {
|
||||||
@ -269,5 +232,5 @@ export async function listInbox(prisma: PrismaClient, actor: Actor): Promise<Inb
|
|||||||
orderBy: { seq: "desc" },
|
orderBy: { seq: "desc" },
|
||||||
select: { id: true, runId: true, blocks: true, createdAt: true },
|
select: { id: true, runId: true, blocks: true, createdAt: true },
|
||||||
});
|
});
|
||||||
return buildInboxItems({ runs, messages, unreadThreads, otherSpaces });
|
return buildInboxItems({ runs, messages, unreadThreads });
|
||||||
}
|
}
|
||||||
|
|||||||
@ -7,7 +7,6 @@ import { serve } from "@hono/node-server";
|
|||||||
import { SERVICE_NAMES } from "@rakazo/logging";
|
import { SERVICE_NAMES } from "@rakazo/logging";
|
||||||
import { createRootLogger } from "@rakazo/logging/axiom";
|
import { createRootLogger } from "@rakazo/logging/axiom";
|
||||||
import { createApp } from "./app.js";
|
import { createApp } from "./app.js";
|
||||||
import { API_SERVER_OPTIONS } from "./artifact-http.js";
|
|
||||||
import { loadEnv } from "./env.js";
|
import { loadEnv } from "./env.js";
|
||||||
|
|
||||||
const logger = createRootLogger(SERVICE_NAMES.api);
|
const logger = createRootLogger(SERVICE_NAMES.api);
|
||||||
@ -15,17 +14,9 @@ const logger = createRootLogger(SERVICE_NAMES.api);
|
|||||||
try {
|
try {
|
||||||
const env = loadEnv();
|
const env = loadEnv();
|
||||||
const { app, stop } = await createApp({ ...env, logger });
|
const { app, stop } = await createApp({ ...env, logger });
|
||||||
const server = serve(
|
const server = serve({ fetch: app.fetch, port: env.port, hostname: env.apiHost }, () => {
|
||||||
{
|
logger.info("api listening", { "http.host": env.apiHost, "http.port": env.port });
|
||||||
fetch: app.fetch,
|
});
|
||||||
port: env.port,
|
|
||||||
hostname: env.apiHost,
|
|
||||||
serverOptions: API_SERVER_OPTIONS,
|
|
||||||
},
|
|
||||||
() => {
|
|
||||||
logger.info("api listening", { "http.host": env.apiHost, "http.port": env.port });
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
// Long-lived connections (threads.subscribe SSE streams) never end on their
|
// Long-lived connections (threads.subscribe SSE streams) never end on their
|
||||||
// own, so server.close() alone waits forever for them. Track sockets and
|
// own, so server.close() alone waits forever for them. Track sockets and
|
||||||
|
|||||||
@ -43,20 +43,13 @@ export async function assertNotMainConversation(
|
|||||||
if (group?.kind === "main") throw new ORPCError("BAD_REQUEST", { message });
|
if (group?.kind === "main") throw new ORPCError("BAD_REQUEST", { message });
|
||||||
}
|
}
|
||||||
|
|
||||||
/** A main conversation carries its main bot's name. */
|
/** The main conversation carries the main bot's name. */
|
||||||
export async function renameMainConversation(
|
export async function renameMainConversation(prisma: PrismaClient, userId: string, name: string) {
|
||||||
prisma: PrismaClient,
|
await prisma.chatGroup.updateMany({ where: { userId, kind: "main" }, data: { name } });
|
||||||
main: { userId: string; spaceId: string },
|
|
||||||
name: string,
|
|
||||||
) {
|
|
||||||
await prisma.chatGroup.updateMany({
|
|
||||||
where: { userId: main.userId, spaceId: main.spaceId, kind: "main" },
|
|
||||||
data: { name },
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Same-space bots the user addresses in a main conversation, by `@Name` or a mention chip,
|
* Same-space bots the user addresses in the main conversation, by `@Name` or a mention chip,
|
||||||
* join it first, so the member mention rules route the message to them.
|
* join it first, so the member mention rules route the message to them.
|
||||||
*/
|
*/
|
||||||
export async function joinAddressedBots(
|
export async function joinAddressedBots(
|
||||||
@ -78,9 +71,5 @@ export async function joinAddressedBots(
|
|||||||
.map((bot) => bot.id);
|
.map((bot) => bot.id);
|
||||||
if (addressed.length === 0) return;
|
if (addressed.length === 0) return;
|
||||||
// Subscribers pick up the group.updated event with the send that follows.
|
// Subscribers pick up the group.updated event with the send that follows.
|
||||||
await joinMainConversation(prisma, {
|
await joinMainConversation(prisma, { userId: actor.userId, botIds: addressed });
|
||||||
userId: actor.userId,
|
|
||||||
spaceId: actor.spaceId,
|
|
||||||
botIds: addressed,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,34 +0,0 @@
|
|||||||
import type { MediaCapabilities } from "@rakazo/adapter-kit";
|
|
||||||
import type { Actor } from "@rakazo/contracts";
|
|
||||||
import { describe, expect, it } from "vitest";
|
|
||||||
import type { MediaRouteDeps } from "./media-jobs.js";
|
|
||||||
import { getMediaCapabilities } from "./media-jobs.js";
|
|
||||||
|
|
||||||
const actor: Actor = {
|
|
||||||
userId: "user-1",
|
|
||||||
spaceId: "space-1",
|
|
||||||
email: "user@rakazo.test",
|
|
||||||
isDeploymentOwner: false,
|
|
||||||
};
|
|
||||||
|
|
||||||
describe("media capabilities", () => {
|
|
||||||
it("answers for the actor's space", async () => {
|
|
||||||
const asked: string[] = [];
|
|
||||||
const deps = {
|
|
||||||
media: {
|
|
||||||
capabilities: async (spaceId: string) => {
|
|
||||||
asked.push(spaceId);
|
|
||||||
return { transcribe: true, export_video: false } as MediaCapabilities;
|
|
||||||
},
|
|
||||||
},
|
|
||||||
} as unknown as MediaRouteDeps;
|
|
||||||
await expect(getMediaCapabilities(deps, actor)).resolves.toMatchObject({ transcribe: true });
|
|
||||||
expect(asked).toEqual(["space-1"]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("reports nothing available without a media runtime", async () => {
|
|
||||||
const capabilities = await getMediaCapabilities({} as MediaRouteDeps, actor);
|
|
||||||
expect(Object.values(capabilities)).toHaveLength(6);
|
|
||||||
expect(Object.values(capabilities).every((value) => value === false)).toBe(true);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@ -1,165 +0,0 @@
|
|||||||
import { ORPCError } from "@orpc/server";
|
|
||||||
import type { MediaCapabilities, MediaRuntime } from "@rakazo/adapter-kit";
|
|
||||||
import type { EncryptedSecretStore } from "@rakazo/adapters";
|
|
||||||
import {
|
|
||||||
loadMediaSettings,
|
|
||||||
MediaJobRejectedError,
|
|
||||||
MediaSettingsInvalidError,
|
|
||||||
mediaSettingsView,
|
|
||||||
saveMediaSettings,
|
|
||||||
} from "@rakazo/adapters";
|
|
||||||
import type { Actor, MediaJobStartInput, MediaSettingsUpdate } from "@rakazo/contracts";
|
|
||||||
import { MediaOperationSchema } from "@rakazo/contracts";
|
|
||||||
import type { PrismaClient } from "@rakazo/db";
|
|
||||||
import { IsolationError, requireMembership } from "@rakazo/db";
|
|
||||||
import { resolveThreadTarget } from "./thread-target.js";
|
|
||||||
|
|
||||||
export interface MediaRouteDeps {
|
|
||||||
prisma: PrismaClient;
|
|
||||||
secrets: EncryptedSecretStore;
|
|
||||||
media?: MediaRuntime;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Media backends run on the deployment owner's own server, so only they configure them. */
|
|
||||||
function requireDeploymentOwner(actor: Actor) {
|
|
||||||
if (!actor.isDeploymentOwner) throw new ORPCError("FORBIDDEN");
|
|
||||||
}
|
|
||||||
|
|
||||||
function requireMedia(deps: MediaRouteDeps): MediaRuntime {
|
|
||||||
if (!deps.media) throw new ORPCError("NOT_IMPLEMENTED");
|
|
||||||
return deps.media;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function getMediaSettings(deps: MediaRouteDeps, actor: Actor) {
|
|
||||||
requireDeploymentOwner(actor);
|
|
||||||
return mediaSettingsView(await loadMediaSettings(deps));
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function updateMediaSettings(
|
|
||||||
deps: MediaRouteDeps,
|
|
||||||
actor: Actor,
|
|
||||||
input: MediaSettingsUpdate,
|
|
||||||
) {
|
|
||||||
requireDeploymentOwner(actor);
|
|
||||||
try {
|
|
||||||
return await saveMediaSettings(deps, input);
|
|
||||||
} catch (error) {
|
|
||||||
if (error instanceof MediaSettingsInvalidError) {
|
|
||||||
throw new ORPCError("BAD_REQUEST", { message: error.message });
|
|
||||||
}
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The actor in the space a card lives in. A card is acted on where it is shown, which need not be
|
|
||||||
* the space the client has selected (the main conversation lives in the home space), so the
|
|
||||||
* space comes from the card's own record: it must belong to the actor, and the actor must still
|
|
||||||
* be a member of that space. Anything else reads as not found.
|
|
||||||
*/
|
|
||||||
async function actorInSpace(deps: MediaRouteDeps, actor: Actor, spaceId: string): Promise<Actor> {
|
|
||||||
if (spaceId === actor.spaceId) return actor;
|
|
||||||
try {
|
|
||||||
return await requireMembership(deps.prisma, actor.userId, spaceId);
|
|
||||||
} catch (error) {
|
|
||||||
if (error instanceof IsolationError) throw new ORPCError("NOT_FOUND");
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Starts an operation a user picked on a card (export, transcribe, edit) in the card's
|
|
||||||
* conversation. The conversation is authorized like every thread procedure, in the card's space;
|
|
||||||
* the runtime checks that the operation's source artifacts belong to the actor there, and the
|
|
||||||
* job can only ever read the actor's artifacts of that space.
|
|
||||||
*/
|
|
||||||
export async function startMediaJob(deps: MediaRouteDeps, actor: Actor, input: MediaJobStartInput) {
|
|
||||||
const media = requireMedia(deps);
|
|
||||||
const source = await deps.prisma.message.findFirst({
|
|
||||||
where: {
|
|
||||||
id: input.messageId,
|
|
||||||
thread: {
|
|
||||||
userId: actor.userId,
|
|
||||||
...(input.botId ? { botId: input.botId } : { groupId: input.groupId }),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
select: { botId: true, thread: { select: { spaceId: true } } },
|
|
||||||
});
|
|
||||||
if (!source) throw new ORPCError("NOT_FOUND");
|
|
||||||
const scoped = await actorInSpace(deps, actor, source.thread.spaceId);
|
|
||||||
const target = await resolveThreadTarget(deps.prisma, scoped, input).catch((error) => {
|
|
||||||
if (error instanceof IsolationError) throw new ORPCError("NOT_FOUND");
|
|
||||||
throw error;
|
|
||||||
});
|
|
||||||
const bots =
|
|
||||||
target.kind === "bot"
|
|
||||||
? [target.botId]
|
|
||||||
: [
|
|
||||||
...(target.leadBotId && target.memberBotIds.includes(target.leadBotId)
|
|
||||||
? [target.leadBotId]
|
|
||||||
: []),
|
|
||||||
...target.memberBotIds,
|
|
||||||
];
|
|
||||||
const botId = source.botId && bots.includes(source.botId) ? source.botId : bots[0];
|
|
||||||
if (!botId) throw new ORPCError("NOT_FOUND");
|
|
||||||
try {
|
|
||||||
return await media.start({
|
|
||||||
operation: input.operation,
|
|
||||||
input: input.input,
|
|
||||||
spaceId: scoped.spaceId,
|
|
||||||
userId: scoped.userId,
|
|
||||||
botId,
|
|
||||||
groupId: target.kind === "group" ? target.groupId : null,
|
|
||||||
threadId: target.threadId,
|
|
||||||
runId: null,
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
if (error instanceof MediaJobRejectedError) {
|
|
||||||
throw new ORPCError("BAD_REQUEST", { message: error.message });
|
|
||||||
}
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Booleans only: which operations a space can run, never how they are configured. With a job,
|
|
||||||
* the space is the job's (a card's follow-ups); otherwise the actor's current space.
|
|
||||||
*/
|
|
||||||
export async function getMediaCapabilities(
|
|
||||||
deps: MediaRouteDeps,
|
|
||||||
actor: Actor,
|
|
||||||
input: { jobId?: string } = {},
|
|
||||||
): Promise<MediaCapabilities> {
|
|
||||||
const spaceId = input.jobId ? (await ownedJob(deps, actor, input.jobId)).spaceId : actor.spaceId;
|
|
||||||
if (deps.media) return deps.media.capabilities(spaceId);
|
|
||||||
return Object.fromEntries(
|
|
||||||
MediaOperationSchema.options.map((operation) => [operation, false]),
|
|
||||||
) as MediaCapabilities;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** The actor's job, in whichever of the actor's spaces it runs. */
|
|
||||||
async function ownedJob(deps: MediaRouteDeps, actor: Actor, jobId: string) {
|
|
||||||
const job = await deps.prisma.mediaJob.findFirst({
|
|
||||||
where: { id: jobId, userId: actor.userId },
|
|
||||||
select: { id: true, spaceId: true },
|
|
||||||
});
|
|
||||||
if (!job) throw new ORPCError("NOT_FOUND");
|
|
||||||
await actorInSpace(deps, actor, job.spaceId);
|
|
||||||
return job;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function getMediaJob(deps: MediaRouteDeps, actor: Actor, jobId: string) {
|
|
||||||
const media = requireMedia(deps);
|
|
||||||
const snapshot = await media.get((await ownedJob(deps, actor, jobId)).id);
|
|
||||||
if (!snapshot) throw new ORPCError("NOT_FOUND");
|
|
||||||
return snapshot;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function cancelMediaJob(deps: MediaRouteDeps, actor: Actor, jobId: string) {
|
|
||||||
const media = requireMedia(deps);
|
|
||||||
const { id } = await ownedJob(deps, actor, jobId);
|
|
||||||
await media.cancel(id);
|
|
||||||
const snapshot = await media.get(id);
|
|
||||||
if (!snapshot) throw new ORPCError("NOT_FOUND");
|
|
||||||
return snapshot;
|
|
||||||
}
|
|
||||||
@ -1,108 +0,0 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
|
||||||
import {
|
|
||||||
closingNote,
|
|
||||||
focusCard,
|
|
||||||
intakeContext,
|
|
||||||
intakeStep,
|
|
||||||
nextIntakeStep,
|
|
||||||
readIntakeAnswer,
|
|
||||||
secondaryOption,
|
|
||||||
} from "./onboarding-intake.js";
|
|
||||||
|
|
||||||
// Intl may separate the time from AM/PM with a narrow no-break space.
|
|
||||||
const plain = (text: string) => text.replace(/\s/g, " ");
|
|
||||||
const off = { morning: null, evening: null, weekly: null };
|
|
||||||
|
|
||||||
describe("onboarding steps", () => {
|
|
||||||
it("asks in order and offers notifications only to a device that can still turn push on", () => {
|
|
||||||
expect(nextIntakeStep("focus", { canEnablePush: false })?.id).toBe("goal");
|
|
||||||
expect(nextIntakeStep("goal", { canEnablePush: false })?.id).toBe("rhythm");
|
|
||||||
expect(nextIntakeStep("rhythm", { canEnablePush: false })?.id).toBe("quiet_hours");
|
|
||||||
expect(nextIntakeStep("quiet_hours", { canEnablePush: true })?.id).toBe("notifications");
|
|
||||||
expect(nextIntakeStep("quiet_hours", { canEnablePush: false })).toBeNull();
|
|
||||||
expect(nextIntakeStep("notifications", { canEnablePush: true })).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("gives every onboarding card a quiet way out, and another bot's focus card none", () => {
|
|
||||||
const context = intakeContext(null, new Date());
|
|
||||||
for (const id of ["focus", "goal", "rhythm", "quiet_hours", "notifications"] as const) {
|
|
||||||
const card = intakeStep(id).card(context);
|
|
||||||
expect(card.step).toBe(id);
|
|
||||||
expect(secondaryOption(card)).toBeDefined();
|
|
||||||
}
|
|
||||||
expect(focusCard().step).toBeUndefined();
|
|
||||||
expect(secondaryOption(focusCard())).toBeUndefined();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("writes quiet hours in the user's locale", () => {
|
|
||||||
const labels = (locale: string | null) =>
|
|
||||||
intakeStep("quiet_hours")
|
|
||||||
.card(intakeContext({ locale, timeZone: null }, new Date()))
|
|
||||||
.options.map((option) => plain(option.label));
|
|
||||||
expect(labels("de-AT")).toEqual(["22:00 – 7:00", "23:00 – 8:00", "Skip"]);
|
|
||||||
expect(labels("en-US")).toEqual(["10:00 PM – 7:00 AM", "11:00 PM – 8:00 AM", "Skip"]);
|
|
||||||
expect(labels("not a tag")).toEqual(labels("en"));
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("readIntakeAnswer", () => {
|
|
||||||
const goal = intakeStep("goal").card(intakeContext(null, new Date()));
|
|
||||||
const rhythm = intakeStep("rhythm").card(intakeContext(null, new Date()));
|
|
||||||
|
|
||||||
it("accepts the card's own options and words only where the card takes them", () => {
|
|
||||||
expect(readIntakeAnswer(rhythm, "weekly", undefined)).toEqual({
|
|
||||||
option: { id: "weekly", letter: "C", label: "Once a week" },
|
|
||||||
});
|
|
||||||
expect(readIntakeAnswer(rhythm, "skip", undefined)).toBeNull();
|
|
||||||
expect(readIntakeAnswer(rhythm, "_text", "Mornings")).toBeNull();
|
|
||||||
expect(readIntakeAnswer(goal, "_text", " Run a 10K ")).toEqual({ text: "Run a 10K" });
|
|
||||||
expect(readIntakeAnswer(goal, "_text", " ")).toBeNull();
|
|
||||||
expect(readIntakeAnswer(goal, "skip", undefined)?.option?.label).toBe("Skip");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("closingNote", () => {
|
|
||||||
const vienna = (iso: string, locale = "de-AT") =>
|
|
||||||
intakeContext({ locale, timeZone: "Europe/Vienna" }, new Date(iso));
|
|
||||||
const note = (rhythm: Parameters<typeof closingNote>[0], context: ReturnType<typeof vienna>) => {
|
|
||||||
const { nextCheckInAt, text } = closingNote(rhythm, context);
|
|
||||||
return { nextCheckInAt, text: plain(text) };
|
|
||||||
};
|
|
||||||
|
|
||||||
it("names the first check-in in the user's time zone", () => {
|
|
||||||
const morning = { ...off, morning: "08:00" };
|
|
||||||
expect(note(morning, vienna("2026-10-07T04:00:00Z"))).toEqual({
|
|
||||||
nextCheckInAt: "2026-10-07T06:00:00.000Z",
|
|
||||||
text: "All set. I’ll check in today at 8:00.",
|
|
||||||
});
|
|
||||||
expect(note(morning, vienna("2026-10-07T09:00:00Z"))).toEqual({
|
|
||||||
nextCheckInAt: "2026-10-08T06:00:00.000Z",
|
|
||||||
text: "All set. I’ll check in tomorrow at 8:00.",
|
|
||||||
});
|
|
||||||
expect(note({ ...morning, evening: "18:00" }, vienna("2026-10-07T09:00:00Z")).text).toBe(
|
|
||||||
"All set. I’ll check in today at 18:00.",
|
|
||||||
);
|
|
||||||
// Wednesday in Vienna; the weekly review is on Sunday.
|
|
||||||
const weekly = { ...off, weekly: { day: 0, time: "18:00" } };
|
|
||||||
expect(note(weekly, vienna("2026-10-07T09:00:00Z", "en-US"))).toEqual({
|
|
||||||
nextCheckInAt: "2026-10-11T16:00:00.000Z",
|
|
||||||
text: "All set. I’ll check in on Sunday at 6:00 PM.",
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("says the user can just write when no check-in is set", () => {
|
|
||||||
expect(closingNote(off, vienna("2026-10-07T09:00:00Z"))).toEqual({
|
|
||||||
kind: "intake_note",
|
|
||||||
note: "done",
|
|
||||||
nextCheckInAt: null,
|
|
||||||
text: "All set. I’m here whenever you need me.",
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("falls back to English and UTC for an unusable profile", () => {
|
|
||||||
expect(intakeContext({ locale: "??", timeZone: "Mars/Olympus" }, new Date(0))).toMatchObject({
|
|
||||||
locale: "en",
|
|
||||||
timeZone: "UTC",
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@ -1,297 +0,0 @@
|
|||||||
import type { JobPublisher } from "@rakazo/adapter-kit";
|
|
||||||
import { COACH_KEYS, coachCron, setCoachRhythm } from "@rakazo/adapters";
|
|
||||||
import type { CoachRhythm, MessageBlock, OnboardingStep, QuietHours } from "@rakazo/contracts";
|
|
||||||
import { CHOICE_TEXT_ANSWER_ID } from "@rakazo/contracts";
|
|
||||||
import { knownTimeZone, nextCronDateAcross } from "@rakazo/core";
|
|
||||||
import type { Prisma, PrismaClient } from "@rakazo/db";
|
|
||||||
import { createGoal, updateNotificationSettings, updateUserProfile } from "@rakazo/db";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The questions the main bot asks a new user in the main conversation, one card at a time.
|
|
||||||
* Each step is a card plus what its answer changes; the order of `INTAKE_STEPS` is the order
|
|
||||||
* the user sees. Everything here is deterministic, so no model tokens are spent.
|
|
||||||
*/
|
|
||||||
|
|
||||||
export type ChoiceBlock = Extract<MessageBlock, { kind: "choice" }>;
|
|
||||||
type ChoiceOption = ChoiceBlock["options"][number];
|
|
||||||
|
|
||||||
/** A validated answer: an option of the card, or the user's own words. */
|
|
||||||
export type IntakeAnswer =
|
|
||||||
| { option: ChoiceOption; text?: undefined }
|
|
||||||
| { option?: undefined; text: string };
|
|
||||||
|
|
||||||
/** What a card may adapt to: times are written in the user's locale. */
|
|
||||||
export interface IntakeContext {
|
|
||||||
locale: string;
|
|
||||||
timeZone: string;
|
|
||||||
now: Date;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface IntakeEffects {
|
|
||||||
prisma: PrismaClient;
|
|
||||||
jobs: JobPublisher;
|
|
||||||
tx: Prisma.TransactionClient;
|
|
||||||
userId: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface IntakeStep {
|
|
||||||
id: OnboardingStep;
|
|
||||||
/** Asked only when this holds; otherwise onboarding moves past the step. */
|
|
||||||
asks?: (device: { canEnablePush: boolean }) => boolean;
|
|
||||||
card: (context: IntakeContext) => ChoiceBlock;
|
|
||||||
/** Records a validated answer. Runs while the main conversation is locked. */
|
|
||||||
save: (effects: IntakeEffects, answer: IntakeAnswer) => Promise<void>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface FocusOption {
|
|
||||||
id: string;
|
|
||||||
letter: string;
|
|
||||||
label: string;
|
|
||||||
apps: string[];
|
|
||||||
/** Stored as a `goals` fact, so it stays a plain English statement. */
|
|
||||||
fact: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const FOCUS_OPTIONS: FocusOption[] = [
|
|
||||||
{
|
|
||||||
id: "day",
|
|
||||||
letter: "A",
|
|
||||||
label: "Day-to-day work",
|
|
||||||
apps: ["slack", "gmail", "googlecalendar"],
|
|
||||||
fact: "Wants help with day-to-day work first",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "inbox",
|
|
||||||
letter: "B",
|
|
||||||
label: "Inbox & email",
|
|
||||||
apps: ["gmail", "googlecalendar", "slack"],
|
|
||||||
fact: "Wants help with inbox and email first",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "research",
|
|
||||||
letter: "C",
|
|
||||||
label: "Research & writing",
|
|
||||||
apps: ["hackernews", "notion", "googledocs"],
|
|
||||||
fact: "Wants help with research and writing first",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "crew",
|
|
||||||
letter: "D",
|
|
||||||
label: "Specialist crew",
|
|
||||||
apps: ["slack", "gmail", "googlecalendar"],
|
|
||||||
fact: "Wants a coordinated crew of specialist bots",
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
const SKIP: ChoiceOption = { id: "skip", letter: "", label: "Skip" };
|
|
||||||
|
|
||||||
/** The focus card. Onboarding adds a skip; another bot's card is dismissed instead. */
|
|
||||||
export function focusCard(step?: "focus"): ChoiceBlock {
|
|
||||||
return {
|
|
||||||
kind: "choice",
|
|
||||||
question: "What do you want me on first?",
|
|
||||||
options: [
|
|
||||||
...FOCUS_OPTIONS.map(({ id, letter, label }) => ({ id, letter, label })),
|
|
||||||
...(step ? [SKIP] : []),
|
|
||||||
],
|
|
||||||
...(step ? { step } : {}),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const RHYTHMS: Record<string, CoachRhythm> = {
|
|
||||||
morning: { morning: "08:00", evening: null, weekly: null },
|
|
||||||
morning_evening: { morning: "08:00", evening: "18:00", weekly: null },
|
|
||||||
weekly: { morning: null, evening: null, weekly: { day: 0, time: "18:00" } },
|
|
||||||
none: { morning: null, evening: null, weekly: null },
|
|
||||||
};
|
|
||||||
|
|
||||||
const QUIET_HOURS: Record<string, QuietHours> = {
|
|
||||||
"22-07": { start: "22:00", end: "07:00" },
|
|
||||||
"23-08": { start: "23:00", end: "08:00" },
|
|
||||||
};
|
|
||||||
|
|
||||||
export const INTAKE_STEPS: IntakeStep[] = [
|
|
||||||
{
|
|
||||||
id: "focus",
|
|
||||||
card: () => focusCard("focus"),
|
|
||||||
save: async ({ prisma, tx, userId }, answer) => {
|
|
||||||
const option = FOCUS_OPTIONS.find((entry) => entry.id === answer.option?.id);
|
|
||||||
if (!option) return;
|
|
||||||
await tx.userOnboarding.update({ where: { userId }, data: { focus: option.id } });
|
|
||||||
await updateUserProfile(prisma, userId, {
|
|
||||||
addFacts: [{ category: "goals", value: option.fact, source: "onboarding" }],
|
|
||||||
});
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "goal",
|
|
||||||
card: () => ({
|
|
||||||
kind: "choice",
|
|
||||||
step: "goal",
|
|
||||||
question: "What’s one thing you want to get done in the next few weeks?",
|
|
||||||
textInput: { placeholder: "Your goal" },
|
|
||||||
options: [SKIP],
|
|
||||||
}),
|
|
||||||
save: async ({ prisma, userId }, answer) => {
|
|
||||||
if (answer.text)
|
|
||||||
await createGoal(prisma, userId, { title: answer.text, source: "onboarding" });
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "rhythm",
|
|
||||||
card: () => ({
|
|
||||||
kind: "choice",
|
|
||||||
step: "rhythm",
|
|
||||||
question: "When should I check in with you?",
|
|
||||||
options: [
|
|
||||||
{ id: "morning", letter: "A", label: "Every morning" },
|
|
||||||
{ id: "morning_evening", letter: "B", label: "Morning and evening" },
|
|
||||||
{ id: "weekly", letter: "C", label: "Once a week" },
|
|
||||||
{ id: "none", letter: "", label: "Not now" },
|
|
||||||
],
|
|
||||||
}),
|
|
||||||
save: async ({ prisma, jobs, userId }, answer) => {
|
|
||||||
const rhythm = answer.option && RHYTHMS[answer.option.id];
|
|
||||||
if (rhythm) await setCoachRhythm({ prisma, jobs }, { userId, rhythm });
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "quiet_hours",
|
|
||||||
card: (context) => ({
|
|
||||||
kind: "choice",
|
|
||||||
step: "quiet_hours",
|
|
||||||
question: "When should I stay quiet?",
|
|
||||||
options: [
|
|
||||||
...Object.entries(QUIET_HOURS).map(([id, range], index) => ({
|
|
||||||
id,
|
|
||||||
letter: String.fromCharCode(65 + index),
|
|
||||||
label: `${formatClock(range.start, context.locale)} – ${formatClock(range.end, context.locale)}`,
|
|
||||||
})),
|
|
||||||
SKIP,
|
|
||||||
],
|
|
||||||
}),
|
|
||||||
save: async ({ prisma, userId }, answer) => {
|
|
||||||
const quietHours = answer.option && QUIET_HOURS[answer.option.id];
|
|
||||||
if (quietHours) await updateNotificationSettings(prisma, userId, { quietHours });
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
// The browser subscribes when the user taps "Turn on"; the answer only moves on.
|
|
||||||
id: "notifications",
|
|
||||||
asks: ({ canEnablePush }) => canEnablePush,
|
|
||||||
card: () => ({
|
|
||||||
kind: "choice",
|
|
||||||
step: "notifications",
|
|
||||||
question: "Can I send you notifications?",
|
|
||||||
options: [
|
|
||||||
{ id: "on", letter: "A", label: "Turn on" },
|
|
||||||
{ id: "later", letter: "", label: "Not now" },
|
|
||||||
],
|
|
||||||
}),
|
|
||||||
save: async () => {},
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
export function intakeStep(id: OnboardingStep): IntakeStep {
|
|
||||||
const step = INTAKE_STEPS.find((entry) => entry.id === id);
|
|
||||||
if (!step) throw new Error(`Unknown onboarding step: ${id}`);
|
|
||||||
return step;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** The step after `id` that applies to this device, or null when onboarding is done. */
|
|
||||||
export function nextIntakeStep(
|
|
||||||
id: OnboardingStep,
|
|
||||||
device: { canEnablePush: boolean },
|
|
||||||
): IntakeStep | null {
|
|
||||||
const index = INTAKE_STEPS.findIndex((entry) => entry.id === id);
|
|
||||||
return INTAKE_STEPS.slice(index + 1).find((entry) => entry.asks?.(device) ?? true) ?? null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Checks an answer against the card it answers; null when the card does not offer it. */
|
|
||||||
export function readIntakeAnswer(
|
|
||||||
card: ChoiceBlock,
|
|
||||||
optionId: string,
|
|
||||||
text: string | undefined,
|
|
||||||
): IntakeAnswer | null {
|
|
||||||
if (optionId === CHOICE_TEXT_ANSWER_ID) {
|
|
||||||
const words = text?.trim();
|
|
||||||
return card.textInput && words ? { text: words } : null;
|
|
||||||
}
|
|
||||||
const option = card.options.find((entry) => entry.id === optionId);
|
|
||||||
return option ? { option } : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** The secondary answer ("Skip", "Not now") a dismissed card counts as. */
|
|
||||||
export function secondaryOption(card: ChoiceBlock): ChoiceOption | undefined {
|
|
||||||
return card.options.find((option) => !option.letter);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Card times and the closing line follow the user's profile, falling back to English and UTC. */
|
|
||||||
export function intakeContext(
|
|
||||||
profile: { locale: string | null; timeZone: string | null } | null,
|
|
||||||
now: Date,
|
|
||||||
): IntakeContext {
|
|
||||||
let locale = "en";
|
|
||||||
try {
|
|
||||||
if (profile?.locale) locale = Intl.getCanonicalLocales(profile.locale)[0] ?? "en";
|
|
||||||
} catch {
|
|
||||||
// Not a language tag; keep English.
|
|
||||||
}
|
|
||||||
return { locale, timeZone: knownTimeZone(profile?.timeZone) ?? "UTC", now };
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatClock(time: string, locale: string): string {
|
|
||||||
const [hour = 0, minute = 0] = time.split(":").map(Number);
|
|
||||||
return new Intl.DateTimeFormat(locale, {
|
|
||||||
hour: "numeric",
|
|
||||||
minute: "2-digit",
|
|
||||||
timeZone: "UTC",
|
|
||||||
}).format(new Date(Date.UTC(2000, 0, 1, hour, minute)));
|
|
||||||
}
|
|
||||||
|
|
||||||
function localDay(date: Date, timeZone: string): number {
|
|
||||||
const [year, month, day] = new Intl.DateTimeFormat("en-CA", {
|
|
||||||
timeZone,
|
|
||||||
year: "numeric",
|
|
||||||
month: "2-digit",
|
|
||||||
day: "2-digit",
|
|
||||||
})
|
|
||||||
.format(date)
|
|
||||||
.split("-")
|
|
||||||
.map(Number);
|
|
||||||
return Date.UTC(year ?? 1970, (month ?? 1) - 1, day ?? 1) / 86_400_000;
|
|
||||||
}
|
|
||||||
|
|
||||||
export type IntakeNote = Extract<MessageBlock, { kind: "intake_note" }>;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The closing note: what happens next, i.e. the first check-in when there is one. The web says
|
|
||||||
* it in the user's language from `nextCheckInAt`; `text` is the English line for everything else.
|
|
||||||
*/
|
|
||||||
export function closingNote(rhythm: CoachRhythm, context: IntakeContext): IntakeNote {
|
|
||||||
const crons = COACH_KEYS.flatMap((key) => coachCron(key, rhythm) ?? []);
|
|
||||||
const next = nextCronDateAcross(crons, context.now, context.timeZone);
|
|
||||||
return {
|
|
||||||
kind: "intake_note",
|
|
||||||
note: "done",
|
|
||||||
nextCheckInAt: next?.toISOString() ?? null,
|
|
||||||
text: closingText(next, context),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function closingText(next: Date | null, context: IntakeContext): string {
|
|
||||||
if (!next) return "All set. I’m here whenever you need me.";
|
|
||||||
const days = localDay(next, context.timeZone) - localDay(context.now, context.timeZone);
|
|
||||||
const day =
|
|
||||||
days === 0
|
|
||||||
? "today"
|
|
||||||
: days === 1
|
|
||||||
? "tomorrow"
|
|
||||||
: `on ${new Intl.DateTimeFormat("en-US", { timeZone: context.timeZone, weekday: "long" }).format(next)}`;
|
|
||||||
const time = new Intl.DateTimeFormat(context.locale, {
|
|
||||||
hour: "numeric",
|
|
||||||
minute: "2-digit",
|
|
||||||
timeZone: context.timeZone,
|
|
||||||
}).format(next);
|
|
||||||
return `All set. I’ll check in ${day} at ${time}.`;
|
|
||||||
}
|
|
||||||
@ -1,192 +1,64 @@
|
|||||||
import { mkdir, mkdtemp, realpath, rm, symlink, writeFile } from "node:fs/promises";
|
import { mkdir, mkdtemp, writeFile } from "node:fs/promises";
|
||||||
import { tmpdir } from "node:os";
|
import { tmpdir } from "node:os";
|
||||||
import path from "node:path";
|
import { join } from "node:path";
|
||||||
import type { PrismaClient } from "@rakazo/db";
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
import { afterAll, describe, expect, it, vi } from "vitest";
|
import { getScanStatus, startOnboardingScan } from "./onboarding-scan.js";
|
||||||
import {
|
|
||||||
applyOnboardingScan,
|
|
||||||
discoverOnboardingScan,
|
|
||||||
onboardingScanFolder,
|
|
||||||
onboardingScanStatus,
|
|
||||||
startOnboardingScan,
|
|
||||||
} from "./onboarding-scan.js";
|
|
||||||
|
|
||||||
const owner = { userId: "user-owner", spaceId: "space-owner", isDeploymentOwner: true };
|
const owner = { userId: "user-owner", isDeploymentOwner: true };
|
||||||
const member = { userId: "user-member", spaceId: "space-member", isDeploymentOwner: false };
|
const member = { userId: "user-member", isDeploymentOwner: false };
|
||||||
const roots: string[] = [];
|
|
||||||
|
|
||||||
/** Fails the test if the scan writes anything before its checks pass. */
|
async function scanHomeWithOneFile(): Promise<string> {
|
||||||
const untouchedPrisma = new Proxy(
|
const home = await mkdtemp(join(tmpdir(), "evoo-scan-"));
|
||||||
{},
|
await mkdir(join(home, "Projects"));
|
||||||
{
|
await writeFile(join(home, "Projects", "notes.ts"), "export const x = 1;\n");
|
||||||
get() {
|
return home;
|
||||||
throw new Error("the database must not be touched");
|
|
||||||
},
|
|
||||||
},
|
|
||||||
) as PrismaClient;
|
|
||||||
|
|
||||||
async function tempDir(): Promise<string> {
|
|
||||||
const dir = await realpath(await mkdtemp(path.join(tmpdir(), "evoo-scan-")));
|
|
||||||
roots.push(dir);
|
|
||||||
return dir;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function files(root: string, entries: string[]): Promise<void> {
|
|
||||||
for (const entry of entries) {
|
|
||||||
const file = path.join(root, entry);
|
|
||||||
await mkdir(path.dirname(file), { recursive: true });
|
|
||||||
await writeFile(file, "x\n");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function project(root: string, relative: string, entries: string[]): Promise<void> {
|
|
||||||
await mkdir(path.join(root, relative, ".git"), { recursive: true });
|
|
||||||
await files(path.join(root, relative), entries);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function completed(scanId: string, userId: string) {
|
async function completed(scanId: string, userId: string) {
|
||||||
return await vi.waitFor(
|
return await vi.waitFor(
|
||||||
() => {
|
async () => {
|
||||||
const status = onboardingScanStatus(scanId, userId);
|
const status = await getScanStatus(scanId, userId);
|
||||||
if (status.status === "scanning") throw new Error("pending");
|
if (status.status !== "complete" && status.status !== "failed") throw new Error("pending");
|
||||||
return status;
|
return status;
|
||||||
},
|
},
|
||||||
{ timeout: 10_000, interval: 20 },
|
{ timeout: 10_000, interval: 50 },
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
afterAll(async () => {
|
|
||||||
await Promise.all(roots.map((root) => rm(root, { recursive: true, force: true })));
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("onboarding scan", () => {
|
describe("onboarding scan", () => {
|
||||||
it("lists git projects with their languages and tools and never follows a symlink", async () => {
|
afterEach(() => {
|
||||||
const home = await tempDir();
|
vi.unstubAllEnvs();
|
||||||
const outside = await tempDir();
|
|
||||||
// A home folder tracked by git is not itself a project.
|
|
||||||
await mkdir(path.join(home, ".git"));
|
|
||||||
await project(home, "code/atlas", [
|
|
||||||
"src/a.ts",
|
|
||||||
"src/b.ts",
|
|
||||||
"src/c.ts",
|
|
||||||
"src/d.tsx",
|
|
||||||
"scripts/build.js",
|
|
||||||
"Dockerfile",
|
|
||||||
".github/workflows/ci.yml",
|
|
||||||
"infra/main.tf",
|
|
||||||
"src/odd.constructor",
|
|
||||||
"node_modules/dep/index.js",
|
|
||||||
"node_modules/dep/more.js",
|
|
||||||
]);
|
|
||||||
await project(home, "code/garden", ["main.py"]);
|
|
||||||
await project(home, "deep/a/b/edge", ["tool.py"]);
|
|
||||||
await project(home, "deep/a/b/c/too-deep", ["main.go"]);
|
|
||||||
await project(home, ".hidden/secret", ["main.go"]);
|
|
||||||
await project(home, "notes/node_modules/vendored", ["main.go"]);
|
|
||||||
await files(home, ["notes/todo.md"]);
|
|
||||||
await project(outside, "escaped", ["main.rs"]);
|
|
||||||
await symlink(path.join(outside, "escaped"), path.join(home, "linked"));
|
|
||||||
await symlink(outside, path.join(home, "code/atlas/src/outside"));
|
|
||||||
|
|
||||||
const findings = await discoverOnboardingScan(home);
|
|
||||||
|
|
||||||
expect(findings.projects).toEqual([
|
|
||||||
{
|
|
||||||
id: "project-0",
|
|
||||||
name: "atlas",
|
|
||||||
path: "code/atlas",
|
|
||||||
languages: ["TypeScript", "JavaScript"],
|
|
||||||
tools: ["Docker", "GitHub Actions", "Terraform"],
|
|
||||||
},
|
|
||||||
{ id: "project-1", name: "garden", path: "code/garden", languages: ["Python"], tools: [] },
|
|
||||||
{ id: "project-2", name: "edge", path: "deep/a/b/edge", languages: ["Python"], tools: [] },
|
|
||||||
]);
|
|
||||||
expect(findings.facts).toEqual([
|
|
||||||
{ id: "language:Python", label: "Python", category: "work", value: "Codes in Python" },
|
|
||||||
{
|
|
||||||
id: "language:JavaScript",
|
|
||||||
label: "JavaScript",
|
|
||||||
category: "work",
|
|
||||||
value: "Codes in JavaScript",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "language:TypeScript",
|
|
||||||
label: "TypeScript",
|
|
||||||
category: "work",
|
|
||||||
value: "Codes in TypeScript",
|
|
||||||
},
|
|
||||||
{ id: "tool:Docker", label: "Docker", category: "tools", value: "Uses Docker" },
|
|
||||||
{
|
|
||||||
id: "tool:GitHub Actions",
|
|
||||||
label: "GitHub Actions",
|
|
||||||
category: "tools",
|
|
||||||
value: "Uses GitHub Actions",
|
|
||||||
},
|
|
||||||
{ id: "tool:Terraform", label: "Terraform", category: "tools", value: "Uses Terraform" },
|
|
||||||
]);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("offers a scan folder only to the deployment owner and only when it exists", async () => {
|
it("hides a scan from every account except the one that started it", async () => {
|
||||||
const home = await tempDir();
|
const { scanId } = await startOnboardingScan({ actor: member }, {});
|
||||||
await files(home, ["file.txt"]);
|
await expect(getScanStatus(scanId, owner.userId)).rejects.toThrow(/Scan not found/);
|
||||||
await expect(onboardingScanFolder(member, home)).resolves.toBeNull();
|
await expect(getScanStatus(scanId, member.userId)).resolves.toMatchObject({
|
||||||
await expect(onboardingScanFolder(owner, undefined)).resolves.toBeNull();
|
status: expect.any(String),
|
||||||
await expect(onboardingScanFolder(owner, path.join(home, "missing"))).resolves.toBeNull();
|
});
|
||||||
await expect(onboardingScanFolder(owner, path.join(home, "file.txt"))).resolves.toBeNull();
|
await expect(getScanStatus("scan_missing", member.userId)).rejects.toThrow(/Scan not found/);
|
||||||
await expect(onboardingScanFolder(owner, home)).resolves.toBe(home);
|
|
||||||
await expect(startOnboardingScan(member, home)).rejects.toMatchObject({ code: "FORBIDDEN" });
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("keeps a scan with the account that started it and returns only relative paths", async () => {
|
it("never reads the host without a configured scan home", async () => {
|
||||||
const home = await tempDir();
|
const { scanId } = await startOnboardingScan({ actor: owner }, {});
|
||||||
await project(home, "code/atlas", ["index.ts"]);
|
|
||||||
const { scanId } = await startOnboardingScan(owner, home);
|
|
||||||
|
|
||||||
const status = await completed(scanId, owner.userId);
|
const status = await completed(scanId, owner.userId);
|
||||||
expect(status).toEqual({
|
expect(status.status).toBe("complete");
|
||||||
status: "complete",
|
expect(status.result).toMatchObject({ localRepos: [], fileSummary: { totalFiles: 0 } });
|
||||||
projects: [{ id: "project-0", name: "atlas", path: "code/atlas" }],
|
|
||||||
facts: [{ id: "language:TypeScript", label: "TypeScript" }],
|
|
||||||
});
|
|
||||||
expect(JSON.stringify(status)).not.toContain(home);
|
|
||||||
expect(() => onboardingScanStatus(scanId, member.userId)).toThrow(
|
|
||||||
expect.objectContaining({ code: "NOT_FOUND" }),
|
|
||||||
);
|
|
||||||
expect(() => onboardingScanStatus("missing-scan", owner.userId)).toThrow(
|
|
||||||
expect.objectContaining({ code: "NOT_FOUND" }),
|
|
||||||
);
|
|
||||||
await expect(
|
|
||||||
applyOnboardingScan(untouchedPrisma, member, home, {
|
|
||||||
scanId,
|
|
||||||
projectIds: ["project-0"],
|
|
||||||
factIds: [],
|
|
||||||
}),
|
|
||||||
).rejects.toMatchObject({ code: "NOT_FOUND" });
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("checks ownership, the folder and every chosen project again before writing", async () => {
|
it("lets only the deployment owner discover the configured scan home", async () => {
|
||||||
const home = await tempDir();
|
// The shared test setup pins the emulated runtime, which also keeps the scan simulated.
|
||||||
const outside = await tempDir();
|
vi.stubEnv("AGENT_RUNTIME", "");
|
||||||
await project(home, "code/atlas", ["index.ts"]);
|
vi.stubEnv("SANDBOX_PROVIDER", "");
|
||||||
await project(home, "code/garden", ["main.py"]);
|
const scanHome = await scanHomeWithOneFile();
|
||||||
await project(outside, "elsewhere", ["main.rs"]);
|
|
||||||
const { scanId } = await startOnboardingScan(owner, home);
|
|
||||||
await completed(scanId, owner.userId);
|
|
||||||
const choice = { scanId, projectIds: ["project-1"], factIds: ["language:Python"] };
|
|
||||||
|
|
||||||
await expect(
|
const asMember = await startOnboardingScan({ actor: member, scanHome }, {});
|
||||||
applyOnboardingScan(untouchedPrisma, { ...owner, isDeploymentOwner: false }, home, choice),
|
const memberStatus = await completed(asMember.scanId, member.userId);
|
||||||
).rejects.toMatchObject({ code: "FORBIDDEN" });
|
expect(memberStatus.result).toMatchObject({ fileSummary: { totalFiles: 0 } });
|
||||||
await expect(
|
|
||||||
applyOnboardingScan(untouchedPrisma, owner, outside, choice),
|
|
||||||
).rejects.toMatchObject({ code: "FORBIDDEN" });
|
|
||||||
|
|
||||||
// The chosen project became a symlink out of the scan folder after the scan.
|
const asOwner = await startOnboardingScan({ actor: owner, scanHome }, {});
|
||||||
await rm(path.join(home, "code/garden"), { recursive: true });
|
const ownerStatus = await completed(asOwner.scanId, owner.userId);
|
||||||
await symlink(path.join(outside, "elsewhere"), path.join(home, "code/garden"));
|
expect(ownerStatus.status).toBe("complete");
|
||||||
await expect(applyOnboardingScan(untouchedPrisma, owner, home, choice)).rejects.toMatchObject({
|
expect(ownerStatus.result).toMatchObject({ fileSummary: { totalFiles: 1 } });
|
||||||
code: "BAD_REQUEST",
|
|
||||||
message: "That folder does not exist or is not readable.",
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@ -1,7 +1,6 @@
|
|||||||
import type * as db from "@rakazo/db";
|
import type * as db from "@rakazo/db";
|
||||||
import { describe, expect, it, vi } from "vitest";
|
import { describe, expect, it, vi } from "vitest";
|
||||||
import { chooseFocus, markAppConnected } from "./onboarding.js";
|
import { chooseFocus, markAppConnected } from "./onboarding.js";
|
||||||
import { focusCard } from "./onboarding-intake.js";
|
|
||||||
|
|
||||||
const posted = vi.hoisted(() => [] as Array<{ blocks: unknown[] }>);
|
const posted = vi.hoisted(() => [] as Array<{ blocks: unknown[] }>);
|
||||||
vi.mock("@rakazo/db", async (original) => ({
|
vi.mock("@rakazo/db", async (original) => ({
|
||||||
@ -14,16 +13,16 @@ vi.mock("@rakazo/db", async (original) => ({
|
|||||||
}));
|
}));
|
||||||
function fixture(catalog: unknown[]) {
|
function fixture(catalog: unknown[]) {
|
||||||
posted.length = 0;
|
posted.length = 0;
|
||||||
// A specialist's focus card, waiting for an answer.
|
|
||||||
const pending = vi.fn(async () => [{ id: "choice", blocks: [focusCard()] }]);
|
|
||||||
const tx = {
|
const tx = {
|
||||||
$executeRaw: vi.fn(),
|
$executeRaw: vi.fn(),
|
||||||
message: { findMany: pending, update: vi.fn() },
|
message: {
|
||||||
|
findMany: vi.fn(async () => [{ id: "choice", blocks: [{ kind: "choice", answerId: null }] }]),
|
||||||
|
update: vi.fn(),
|
||||||
|
},
|
||||||
};
|
};
|
||||||
const deps = {
|
const deps = {
|
||||||
prisma: {
|
prisma: {
|
||||||
bot: { findFirst: vi.fn(async () => ({ id: "bot", thread: { id: "thread" } })) },
|
bot: { findFirst: vi.fn(async () => ({ id: "bot", thread: { id: "thread" } })) },
|
||||||
message: { findMany: pending },
|
|
||||||
$transaction: vi.fn(async (fn: (tx: unknown) => unknown) => fn(tx)),
|
$transaction: vi.fn(async (fn: (tx: unknown) => unknown) => fn(tx)),
|
||||||
},
|
},
|
||||||
events: { notify: vi.fn() },
|
events: { notify: vi.fn() },
|
||||||
@ -40,16 +39,17 @@ function fixture(catalog: unknown[]) {
|
|||||||
describe("onboarding connection suggestions", () => {
|
describe("onboarding connection suggestions", () => {
|
||||||
it("does not invent authorization cards when no connector has an app catalog", async () => {
|
it("does not invent authorization cards when no connector has an app catalog", async () => {
|
||||||
const { deps, actor } = fixture([]);
|
const { deps, actor } = fixture([]);
|
||||||
await chooseFocus(deps, actor, "bot", { optionId: "day" });
|
await chooseFocus(deps, actor, "bot", "day");
|
||||||
// The answered card is all that changes; nothing is said around it.
|
expect(posted.flatMap((message) => message.blocks)).not.toContainEqual(
|
||||||
expect(posted).toEqual([]);
|
expect.objectContaining({ kind: "app_connect" }),
|
||||||
expect(deps.events.notify).toHaveBeenCalled();
|
);
|
||||||
|
expect(posted.length).toBeGreaterThan(0);
|
||||||
});
|
});
|
||||||
it("uses the available connector and omits unavailable apps", async () => {
|
it("uses the available connector and omits unavailable apps", async () => {
|
||||||
const { deps, actor } = fixture([
|
const { deps, actor } = fixture([
|
||||||
{ connectorId: "pipedream", slug: "slack", name: "Slack", connected: false, logo: null },
|
{ connectorId: "pipedream", slug: "slack", name: "Slack", connected: false, logo: null },
|
||||||
]);
|
]);
|
||||||
await chooseFocus(deps, actor, "bot", { optionId: "day" });
|
await chooseFocus(deps, actor, "bot", "day");
|
||||||
expect(
|
expect(
|
||||||
posted
|
posted
|
||||||
.flatMap((message) => message.blocks)
|
.flatMap((message) => message.blocks)
|
||||||
@ -57,8 +57,6 @@ describe("onboarding connection suggestions", () => {
|
|||||||
).toEqual([
|
).toEqual([
|
||||||
expect.objectContaining({ connectorId: "pipedream", provider: "slack", name: "Slack" }),
|
expect.objectContaining({ connectorId: "pipedream", provider: "slack", name: "Slack" }),
|
||||||
]);
|
]);
|
||||||
// The cards speak for themselves.
|
|
||||||
expect(posted).toHaveLength(1);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@ -1,47 +1,67 @@
|
|||||||
import { ORPCError } from "@orpc/server";
|
|
||||||
import type { JobPublisher } from "@rakazo/adapter-kit";
|
|
||||||
import type { ConnectorRegistry } from "@rakazo/adapters";
|
import type { ConnectorRegistry } from "@rakazo/adapters";
|
||||||
import { getCoachRhythm } from "@rakazo/adapters";
|
import type { Actor, MessageBlock } from "@rakazo/contracts";
|
||||||
import type { Actor, MessageBlock, OnboardingStep } from "@rakazo/contracts";
|
|
||||||
import { CHOICE_TEXT_ANSWER_ID } from "@rakazo/contracts";
|
|
||||||
import { featuredConnectorProvidersMatch } from "@rakazo/core";
|
import { featuredConnectorProvidersMatch } from "@rakazo/core";
|
||||||
import {
|
import {
|
||||||
appendEventInTransaction,
|
appendEventInTransaction,
|
||||||
createThreadMessageInTransaction,
|
createThreadMessageInTransaction,
|
||||||
ensureMainConversation,
|
ensureMainConversation,
|
||||||
IsolationError,
|
IsolationError,
|
||||||
isPersonalMainBot,
|
|
||||||
type Prisma,
|
type Prisma,
|
||||||
type PrismaClient,
|
type PrismaClient,
|
||||||
type ThreadEvents,
|
type ThreadEvents,
|
||||||
} from "@rakazo/db";
|
} from "@rakazo/db";
|
||||||
import type { ChoiceBlock, FocusOption, IntakeAnswer, IntakeContext } from "./onboarding-intake.js";
|
|
||||||
import {
|
|
||||||
closingNote,
|
|
||||||
FOCUS_OPTIONS,
|
|
||||||
focusCard,
|
|
||||||
intakeContext,
|
|
||||||
intakeStep,
|
|
||||||
nextIntakeStep,
|
|
||||||
readIntakeAnswer,
|
|
||||||
secondaryOption,
|
|
||||||
} from "./onboarding-intake.js";
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Conversational onboarding, posted deterministically into a bot's thread; no model tokens are
|
* First-run conversational onboarding, seeded deterministically into the bot's
|
||||||
* spent. The main bot asks the onboarding questions (`onboarding-intake.ts`) in the main
|
* thread: greeting, a focus choice, and available app cards the user authorizes
|
||||||
* conversation, one card after each answer. Any other bot gets one focus card that suggests
|
* inline. Focus must not rename the bot. No model tokens are spent. The main bot
|
||||||
* apps the user authorizes inline. Focus never renames a bot.
|
* talks in the main conversation, where onboarding lands.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
type OnboardingDeps = {
|
type OnboardingDeps = {
|
||||||
prisma: PrismaClient;
|
prisma: PrismaClient;
|
||||||
events: ThreadEvents;
|
events: ThreadEvents;
|
||||||
connectors: ConnectorRegistry;
|
connectors: ConnectorRegistry;
|
||||||
jobs: JobPublisher;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
type Target = { spaceId: string; botId: string; threadId: string };
|
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: "crew",
|
||||||
|
letter: "D",
|
||||||
|
label: "Specialist crew",
|
||||||
|
summary: "a coordinated team of focused Oos",
|
||||||
|
apps: ["slack", "gmail", "googlecalendar"],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
const APP_DESCRIPTIONS: Record<string, string> = {
|
const APP_DESCRIPTIONS: Record<string, string> = {
|
||||||
slack: "Search, read, and send messages.",
|
slack: "Search, read, and send messages.",
|
||||||
@ -59,108 +79,80 @@ async function requireBotThread(deps: OnboardingDeps, actor: Actor, botId: strin
|
|||||||
});
|
});
|
||||||
if (!bot?.thread) throw new IsolationError();
|
if (!bot?.thread) throw new IsolationError();
|
||||||
if (bot.kind === "main") {
|
if (bot.kind === "main") {
|
||||||
const conversation = await ensureMainConversation(deps.prisma, actor.userId, bot.spaceId);
|
const conversation = await ensureMainConversation(deps.prisma, actor.userId);
|
||||||
return { bot, thread: { id: conversation.threadId } };
|
return { bot, thread: { id: conversation.threadId } };
|
||||||
}
|
}
|
||||||
return { bot, thread: bot.thread };
|
return { bot, thread: bot.thread };
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
async function post(
|
||||||
* Serializes card writes on a thread. NO KEY UPDATE still lets an answer's effects insert rows
|
deps: OnboardingDeps,
|
||||||
* that reference the thread from another connection (the coach routines do).
|
target: { spaceId: string; botId: string; threadId: string },
|
||||||
*/
|
|
||||||
async function lockThread(tx: Prisma.TransactionClient, threadId: string): Promise<void> {
|
|
||||||
await tx.$executeRaw`SELECT id FROM threads WHERE id = ${threadId} FOR NO KEY UPDATE`;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function postInTransaction(
|
|
||||||
tx: Prisma.TransactionClient,
|
|
||||||
target: Target,
|
|
||||||
blocks: MessageBlock[],
|
blocks: MessageBlock[],
|
||||||
): Promise<{ seq: number }> {
|
): Promise<string> {
|
||||||
const message = await createThreadMessageInTransaction(tx, {
|
const committed = await deps.prisma.$transaction(async (tx: Prisma.TransactionClient) => {
|
||||||
threadId: target.threadId,
|
const message = await createThreadMessageInTransaction(tx, {
|
||||||
role: "bot",
|
threadId: target.threadId,
|
||||||
blocks,
|
role: "bot",
|
||||||
botId: target.botId,
|
blocks,
|
||||||
});
|
botId: target.botId,
|
||||||
return appendEventInTransaction(tx, {
|
});
|
||||||
spaceId: target.spaceId,
|
const event = await appendEventInTransaction(tx, {
|
||||||
threadId: target.threadId,
|
spaceId: target.spaceId,
|
||||||
botId: target.botId,
|
threadId: target.threadId,
|
||||||
type: "thread.message.created",
|
botId: target.botId,
|
||||||
payload: { messageId: message.id, role: "bot", blocks },
|
type: "thread.message.created",
|
||||||
});
|
payload: { messageId: message.id, role: "bot", blocks },
|
||||||
}
|
});
|
||||||
|
return { message, event };
|
||||||
async function updateBlocksInTransaction(
|
|
||||||
tx: Prisma.TransactionClient,
|
|
||||||
target: Target,
|
|
||||||
messageId: string,
|
|
||||||
blocks: MessageBlock[],
|
|
||||||
): Promise<{ seq: number }> {
|
|
||||||
await tx.message.update({ where: { id: messageId }, data: { blocks } });
|
|
||||||
return appendEventInTransaction(tx, {
|
|
||||||
spaceId: target.spaceId,
|
|
||||||
threadId: target.threadId,
|
|
||||||
botId: target.botId,
|
|
||||||
type: "thread.message.updated",
|
|
||||||
payload: { messageId, role: "bot", blocks },
|
|
||||||
});
|
});
|
||||||
|
await deps.events.notify(target.threadId, committed.event.seq);
|
||||||
|
return committed.message.id;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function updateBlocks(
|
async function updateBlocks(
|
||||||
deps: OnboardingDeps,
|
deps: OnboardingDeps,
|
||||||
target: Target,
|
target: { spaceId: string; botId: string; threadId: string },
|
||||||
messageId: string,
|
messageId: string,
|
||||||
blocks: MessageBlock[],
|
blocks: MessageBlock[],
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const event = await deps.prisma.$transaction((tx: Prisma.TransactionClient) =>
|
const event = await deps.prisma.$transaction(async (tx: Prisma.TransactionClient) => {
|
||||||
updateBlocksInTransaction(tx, target, messageId, blocks),
|
await tx.message.update({ where: { id: messageId }, data: { blocks } });
|
||||||
);
|
return appendEventInTransaction(tx, {
|
||||||
|
spaceId: target.spaceId,
|
||||||
|
threadId: target.threadId,
|
||||||
|
botId: target.botId,
|
||||||
|
type: "thread.message.updated",
|
||||||
|
payload: { messageId, role: "bot", blocks },
|
||||||
|
});
|
||||||
|
});
|
||||||
await deps.events.notify(target.threadId, event.seq);
|
await deps.events.notify(target.threadId, event.seq);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Sentinel answerId for a focus card the user dismissed without choosing. */
|
/** Sentinel answerId for a focus card the user dismissed without choosing. */
|
||||||
export const FOCUS_DISMISSED_ANSWER_ID = "_dismissed";
|
export const FOCUS_DISMISSED_ANSWER_ID = "_dismissed";
|
||||||
|
|
||||||
/** Checks that the caller may onboard this bot's thread; fresh threads start empty. */
|
|
||||||
export async function startOnboarding(
|
export async function startOnboarding(
|
||||||
deps: OnboardingDeps,
|
deps: OnboardingDeps,
|
||||||
actor: Actor,
|
actor: Actor,
|
||||||
botId: string,
|
botId: string,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
await requireBotThread(deps, actor, botId);
|
const { thread } = await requireBotThread(deps, actor, botId);
|
||||||
|
const existing = await deps.prisma.message.count({ where: { threadId: thread.id } });
|
||||||
|
if (existing > 0) return;
|
||||||
|
// Fresh chats start empty (same as web). The focus card is posted later via
|
||||||
|
// promptFocus so non-first bots can wait ~10s for free typing, or skip if the
|
||||||
|
// user already engaged.
|
||||||
}
|
}
|
||||||
|
|
||||||
function isChoice(block: MessageBlock): block is ChoiceBlock {
|
function messageHasChoice(blocks: MessageBlock[]): boolean {
|
||||||
return block.kind === "choice";
|
return blocks.some((block) => block.kind === "choice");
|
||||||
}
|
}
|
||||||
|
|
||||||
function pendingChoice(blocks: MessageBlock[]): ChoiceBlock | undefined {
|
function messageHasPendingChoice(blocks: MessageBlock[]): boolean {
|
||||||
return blocks.find((block): block is ChoiceBlock => isChoice(block) && !block.answerId);
|
return blocks.some((block) => block.kind === "choice" && !block.answerId);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The newest card in the thread that still waits for an answer, however long ago it came. */
|
|
||||||
async function findPendingCard(client: PrismaClient | Prisma.TransactionClient, threadId: string) {
|
|
||||||
const messages = await client.message.findMany({
|
|
||||||
where: { threadId, role: "bot", blocks: { array_contains: [{ kind: "choice" }] } },
|
|
||||||
select: { id: true, blocks: true },
|
|
||||||
orderBy: { seq: "desc" },
|
|
||||||
take: 20,
|
|
||||||
});
|
|
||||||
for (const message of messages) {
|
|
||||||
const blocks = message.blocks as MessageBlock[];
|
|
||||||
const card = pendingChoice(blocks);
|
|
||||||
if (card) return { messageId: message.id, blocks, card };
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Posts the first card once. The main bot starts the onboarding questions, one set per user;
|
|
||||||
* any other bot posts its focus card only while its thread is still idle.
|
|
||||||
*/
|
|
||||||
export async function promptFocus(
|
export async function promptFocus(
|
||||||
deps: OnboardingDeps,
|
deps: OnboardingDeps,
|
||||||
actor: Actor,
|
actor: Actor,
|
||||||
@ -168,32 +160,44 @@ export async function promptFocus(
|
|||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const { bot, thread } = await requireBotThread(deps, actor, botId);
|
const { bot, thread } = await requireBotThread(deps, actor, botId);
|
||||||
const target = { spaceId: actor.spaceId, botId: bot.id, threadId: thread.id };
|
const target = { spaceId: actor.spaceId, botId: bot.id, threadId: thread.id };
|
||||||
// The intake runs with the personal main agent; a project's main bot starts like any other.
|
const blocks: MessageBlock[] = [
|
||||||
const main = await isPersonalMainBot(deps.prisma, bot);
|
{
|
||||||
// Check + insert + event in one transaction so concurrent callers cannot duplicate cards,
|
kind: "choice",
|
||||||
// and a concurrent user send cannot publish first.
|
question: "What do you want me on first?",
|
||||||
|
options: FOCUS_OPTIONS.map(({ id, letter, label }) => ({ id, letter, label })),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
// Check + insert + event in one transaction so concurrent promptFocus calls
|
||||||
|
// cannot duplicate cards, and a concurrent user send cannot publish first.
|
||||||
const committed = await deps.prisma.$transaction(async (tx: Prisma.TransactionClient) => {
|
const committed = await deps.prisma.$transaction(async (tx: Prisma.TransactionClient) => {
|
||||||
await lockThread(tx, thread.id);
|
// Serialize concurrent promptFocus callers on this thread before the gate check.
|
||||||
if (main && (await tx.userOnboarding.findUnique({ where: { userId: actor.userId } }))) {
|
await tx.$executeRaw`SELECT id FROM threads WHERE id = ${thread.id} FOR UPDATE`;
|
||||||
return null;
|
|
||||||
}
|
|
||||||
const recent = await tx.message.findMany({
|
const recent = await tx.message.findMany({
|
||||||
where: { threadId: thread.id },
|
where: { threadId: thread.id },
|
||||||
select: { role: true, blocks: true },
|
select: { role: true, blocks: true },
|
||||||
|
orderBy: { createdAt: "asc" },
|
||||||
});
|
});
|
||||||
if (!main && recent.some((message) => message.role === "user")) return null;
|
if (recent.some((message) => message.role === "user")) return null;
|
||||||
if (recent.some((message) => (message.blocks as MessageBlock[]).some(isChoice))) return null;
|
if (recent.some((message) => messageHasChoice(message.blocks as MessageBlock[]))) return null;
|
||||||
if (!main) return postInTransaction(tx, target, [focusCard()]);
|
const message = await createThreadMessageInTransaction(tx, {
|
||||||
const first = intakeStep("focus");
|
threadId: target.threadId,
|
||||||
await tx.userOnboarding.create({ data: { userId: actor.userId, step: first.id } });
|
role: "bot",
|
||||||
return postInTransaction(tx, target, [
|
blocks,
|
||||||
first.card(await loadIntakeContext(tx, actor.userId, new Date())),
|
botId: target.botId,
|
||||||
]);
|
});
|
||||||
|
const event = await appendEventInTransaction(tx, {
|
||||||
|
spaceId: target.spaceId,
|
||||||
|
threadId: target.threadId,
|
||||||
|
botId: target.botId,
|
||||||
|
type: "thread.message.created",
|
||||||
|
payload: { messageId: message.id, role: "bot", blocks },
|
||||||
|
});
|
||||||
|
return { message, event };
|
||||||
});
|
});
|
||||||
if (committed) await deps.events.notify(target.threadId, committed.seq);
|
if (!committed) return;
|
||||||
|
await deps.events.notify(target.threadId, committed.event.seq);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Dismisses the pending card. An onboarding question counts as skipped, so the next follows. */
|
|
||||||
export async function dismissFocus(
|
export async function dismissFocus(
|
||||||
deps: OnboardingDeps,
|
deps: OnboardingDeps,
|
||||||
actor: Actor,
|
actor: Actor,
|
||||||
@ -201,128 +205,81 @@ export async function dismissFocus(
|
|||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const { bot, thread } = await requireBotThread(deps, actor, botId);
|
const { bot, thread } = await requireBotThread(deps, actor, botId);
|
||||||
const target = { spaceId: actor.spaceId, botId: bot.id, threadId: thread.id };
|
const target = { spaceId: actor.spaceId, botId: bot.id, threadId: thread.id };
|
||||||
const pending = await findPendingCard(deps.prisma, thread.id);
|
const claimed = await deps.prisma.$transaction(async (tx: Prisma.TransactionClient) => {
|
||||||
const skip = pending?.card.step ? secondaryOption(pending.card) : undefined;
|
await tx.$executeRaw`SELECT id FROM threads WHERE id = ${thread.id} FOR UPDATE`;
|
||||||
if (pending?.card.step && skip) {
|
const recent = await tx.message.findMany({
|
||||||
await chooseFocus(deps, actor, botId, { step: pending.card.step, optionId: skip.id });
|
where: { threadId: thread.id },
|
||||||
return;
|
orderBy: { createdAt: "asc" },
|
||||||
}
|
});
|
||||||
const event = await deps.prisma.$transaction(async (tx: Prisma.TransactionClient) => {
|
const pending = recent.find((message) =>
|
||||||
await lockThread(tx, thread.id);
|
messageHasPendingChoice(message.blocks as MessageBlock[]),
|
||||||
const current = await findPendingCard(tx, thread.id);
|
|
||||||
if (!current || current.card.step) return null;
|
|
||||||
const blocks = current.blocks.map((block) =>
|
|
||||||
block === current.card ? { ...current.card, answerId: FOCUS_DISMISSED_ANSWER_ID } : block,
|
|
||||||
);
|
);
|
||||||
return updateBlocksInTransaction(tx, target, current.messageId, blocks);
|
if (!pending) return null;
|
||||||
|
const blocks = (pending.blocks as MessageBlock[]).map((block) =>
|
||||||
|
block.kind === "choice" && !block.answerId
|
||||||
|
? { ...block, answerId: FOCUS_DISMISSED_ANSWER_ID }
|
||||||
|
: block,
|
||||||
|
);
|
||||||
|
await tx.message.update({ where: { id: pending.id }, data: { blocks } });
|
||||||
|
const event = await appendEventInTransaction(tx, {
|
||||||
|
spaceId: target.spaceId,
|
||||||
|
threadId: target.threadId,
|
||||||
|
botId: target.botId,
|
||||||
|
type: "thread.message.updated",
|
||||||
|
payload: { messageId: pending.id, role: "bot", blocks },
|
||||||
|
});
|
||||||
|
return { messageId: pending.id, blocks, event };
|
||||||
});
|
});
|
||||||
if (event) await deps.events.notify(target.threadId, event.seq);
|
if (!claimed) return;
|
||||||
|
await deps.events.notify(target.threadId, claimed.event.seq);
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ChooseInput {
|
|
||||||
step?: OnboardingStep;
|
|
||||||
optionId: string;
|
|
||||||
text?: string;
|
|
||||||
canEnablePush?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Answers the pending card. The answer is checked against that card, a stale answer (another
|
|
||||||
* card is pending by now) is ignored, and everything that follows is committed together with
|
|
||||||
* it: the focus card's app suggestions, then the next onboarding question or the closing line.
|
|
||||||
*/
|
|
||||||
export async function chooseFocus(
|
export async function chooseFocus(
|
||||||
deps: OnboardingDeps,
|
deps: OnboardingDeps,
|
||||||
actor: Actor,
|
actor: Actor,
|
||||||
botId: string,
|
botId: string,
|
||||||
input: ChooseInput,
|
optionId: string,
|
||||||
): Promise<void> {
|
): 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 { bot, thread } = await requireBotThread(deps, actor, botId);
|
||||||
const target = { spaceId: actor.spaceId, botId: bot.id, threadId: thread.id };
|
const target = { spaceId: actor.spaceId, botId: bot.id, threadId: thread.id };
|
||||||
const pending = await findPendingCard(deps.prisma, thread.id);
|
|
||||||
if (!pending || pending.card.step !== input.step) return;
|
|
||||||
const answer = readIntakeAnswer(pending.card, input.optionId, input.text);
|
|
||||||
if (!answer) throw new ORPCError("BAD_REQUEST", { message: "This card has no such answer" });
|
|
||||||
const focus =
|
|
||||||
!pending.card.step || pending.card.step === "focus"
|
|
||||||
? FOCUS_OPTIONS.find((option) => option.id === answer.option?.id)
|
|
||||||
: undefined;
|
|
||||||
// Fetched before the transaction: the connector catalog can take seconds.
|
|
||||||
const followUp = focus ? await focusFollowUp(deps, actor, bot.id, focus) : [];
|
|
||||||
|
|
||||||
const event = await deps.prisma.$transaction(async (tx: Prisma.TransactionClient) => {
|
const claimed = await deps.prisma.$transaction(async (tx: Prisma.TransactionClient) => {
|
||||||
await lockThread(tx, thread.id);
|
await tx.$executeRaw`SELECT id FROM threads WHERE id = ${thread.id} FOR UPDATE`;
|
||||||
const current = await findPendingCard(tx, thread.id);
|
const recent = await tx.message.findMany({
|
||||||
if (current?.messageId !== pending.messageId || current.card.step !== input.step) return null;
|
where: { threadId: thread.id },
|
||||||
const onboarding = current.card.step
|
orderBy: { createdAt: "asc" },
|
||||||
? await tx.userOnboarding.findUnique({ where: { userId: actor.userId } })
|
});
|
||||||
: null;
|
const pending = recent.find((message) =>
|
||||||
if (current.card.step) {
|
messageHasPendingChoice(message.blocks as MessageBlock[]),
|
||||||
if (onboarding?.step !== current.card.step) return null;
|
);
|
||||||
await intakeStep(current.card.step).save(
|
if (!pending) return null;
|
||||||
{ prisma: deps.prisma, jobs: deps.jobs, tx, userId: actor.userId },
|
const blocks = (pending.blocks as MessageBlock[]).map((block) =>
|
||||||
answer,
|
block.kind === "choice" ? { ...block, answerId: option.id } : block,
|
||||||
);
|
);
|
||||||
}
|
await tx.message.update({ where: { id: pending.id }, data: { blocks } });
|
||||||
const answered = answeredCard(current.card, answer);
|
const event = await appendEventInTransaction(tx, {
|
||||||
const blocks = current.blocks.map((block) => (block === current.card ? answered : block));
|
spaceId: target.spaceId,
|
||||||
let last = await updateBlocksInTransaction(tx, target, current.messageId, blocks);
|
threadId: target.threadId,
|
||||||
for (const message of followUp) last = await postInTransaction(tx, target, message);
|
botId: target.botId,
|
||||||
if (current.card.step) {
|
type: "thread.message.updated",
|
||||||
last = await askNext(deps, tx, target, actor.userId, current.card.step, input.canEnablePush);
|
payload: { messageId: pending.id, role: "bot", blocks },
|
||||||
}
|
});
|
||||||
return last;
|
return { messageId: pending.id, blocks, event };
|
||||||
});
|
});
|
||||||
if (event) await deps.events.notify(target.threadId, event.seq);
|
if (!claimed) return;
|
||||||
}
|
await deps.events.notify(target.threadId, claimed.event.seq);
|
||||||
|
|
||||||
function answeredCard(card: ChoiceBlock, answer: IntakeAnswer): ChoiceBlock {
|
// Keep the name and title the user chose when creating the bot; the focus
|
||||||
return answer.option
|
// step only suggests apps, it must not rename the bot.
|
||||||
? { ...card, answerId: answer.option.id }
|
await post(deps, target, [
|
||||||
: { ...card, answerId: CHOICE_TEXT_ANSWER_ID, answerText: answer.text };
|
{
|
||||||
}
|
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.`,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
async function loadIntakeContext(
|
|
||||||
tx: Prisma.TransactionClient,
|
|
||||||
userId: string,
|
|
||||||
now: Date,
|
|
||||||
): Promise<IntakeContext> {
|
|
||||||
const profile = await tx.userProfile.findUnique({
|
|
||||||
where: { userId },
|
|
||||||
select: { locale: true, timeZone: true },
|
|
||||||
});
|
|
||||||
return intakeContext(profile, now);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Posts the next onboarding question, or the closing line once every step is answered. */
|
|
||||||
async function askNext(
|
|
||||||
deps: OnboardingDeps,
|
|
||||||
tx: Prisma.TransactionClient,
|
|
||||||
target: Target,
|
|
||||||
userId: string,
|
|
||||||
answeredStep: OnboardingStep,
|
|
||||||
canEnablePush = false,
|
|
||||||
): Promise<{ seq: number }> {
|
|
||||||
const now = new Date();
|
|
||||||
const context = await loadIntakeContext(tx, userId, now);
|
|
||||||
const next = nextIntakeStep(answeredStep, { canEnablePush });
|
|
||||||
if (next) {
|
|
||||||
await tx.userOnboarding.update({ where: { userId }, data: { step: next.id } });
|
|
||||||
return postInTransaction(tx, target, [next.card(context)]);
|
|
||||||
}
|
|
||||||
// The rhythm step saved its answer outside this transaction, so the routines are readable.
|
|
||||||
const rhythm = await getCoachRhythm(deps.prisma, userId);
|
|
||||||
await tx.userOnboarding.update({ where: { userId }, data: { step: null, completedAt: now } });
|
|
||||||
return postInTransaction(tx, target, [closingNote(rhythm, context)]);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** The apps worth connecting for a focus, as cards; they need no words around them. */
|
|
||||||
async function focusFollowUp(
|
|
||||||
deps: OnboardingDeps,
|
|
||||||
actor: Actor,
|
|
||||||
botId: string,
|
|
||||||
option: FocusOption,
|
|
||||||
): Promise<MessageBlock[][]> {
|
|
||||||
const providers = deps.connectors.managedProviders();
|
const providers = deps.connectors.managedProviders();
|
||||||
const catalog = (
|
const catalog = (
|
||||||
await Promise.all(
|
await Promise.all(
|
||||||
@ -333,14 +290,14 @@ async function focusFollowUp(
|
|||||||
traceId: "onboarding.choose",
|
traceId: "onboarding.choose",
|
||||||
spaceId: actor.spaceId,
|
spaceId: actor.spaceId,
|
||||||
userId: actor.userId,
|
userId: actor.userId,
|
||||||
botId,
|
botId: bot.id,
|
||||||
signal: AbortSignal.timeout(15_000),
|
signal: AbortSignal.timeout(15_000),
|
||||||
})
|
})
|
||||||
.catch(() => []),
|
.catch(() => []),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
).flat();
|
).flat();
|
||||||
const cards = option.apps.flatMap((slug) => {
|
const cards: MessageBlock[] = option.apps.flatMap((slug) => {
|
||||||
const entry = catalog.find(
|
const entry = catalog.find(
|
||||||
(item) =>
|
(item) =>
|
||||||
item.slug.toLowerCase() === slug.toLowerCase() ||
|
item.slug.toLowerCase() === slug.toLowerCase() ||
|
||||||
@ -359,7 +316,27 @@ async function focusFollowUp(
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
});
|
});
|
||||||
return cards.length > 0 ? [cards] : [];
|
if (!cards.length) {
|
||||||
|
await post(deps, target, [{ kind: "text", text: "What would you like to work on first?" }]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
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(
|
export async function markAppConnected(
|
||||||
@ -399,3 +376,7 @@ export async function markAppConnected(
|
|||||||
await updateBlocks(deps, target, message.id, next);
|
await updateBlocks(deps, target, message.id, next);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function capitalize(value: string): string {
|
||||||
|
return value.length > 0 ? (value[0] ?? "").toUpperCase() + value.slice(1) : value;
|
||||||
|
}
|
||||||
|
|||||||
@ -55,11 +55,7 @@ export function requestBodyLimit(maxSize: number): MiddlewareHandler {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/** Install limits only on framework-parsed JSON surfaces with known payload contracts. */
|
||||||
* Install limits only on framework-parsed JSON surfaces with known payload contracts. Streaming
|
|
||||||
* artifact uploads (`POST /api/artifacts`) are not buffered: the route enforces the per-kind cap
|
|
||||||
* from Content-Length and again while writing.
|
|
||||||
*/
|
|
||||||
export function mountApiRequestBodyLimits(app: Hono): void {
|
export function mountApiRequestBodyLimits(app: Hono): void {
|
||||||
app.use("/api/auth/*", requestBodyLimit(MAX_AUTH_REQUEST_BYTES));
|
app.use("/api/auth/*", requestBodyLimit(MAX_AUTH_REQUEST_BYTES));
|
||||||
app.use(`${LOCAL_SETTINGS_RPC}/*`, requestBodyLimit(MAX_RPC_REQUEST_BYTES));
|
app.use(`${LOCAL_SETTINGS_RPC}/*`, requestBodyLimit(MAX_RPC_REQUEST_BYTES));
|
||||||
|
|||||||
@ -6,13 +6,11 @@ import type {
|
|||||||
ArtifactStore,
|
ArtifactStore,
|
||||||
ConnectorCatalogItem,
|
ConnectorCatalogItem,
|
||||||
JobPublisher,
|
JobPublisher,
|
||||||
MediaRuntime,
|
|
||||||
MemoryStore,
|
MemoryStore,
|
||||||
SandboxProvider,
|
SandboxProvider,
|
||||||
} from "@rakazo/adapter-kit";
|
} from "@rakazo/adapter-kit";
|
||||||
import {
|
import {
|
||||||
computerControlExpireJobKey,
|
computerControlExpireJobKey,
|
||||||
MemoryDocumentTooLargeError,
|
|
||||||
messagingDeliverJob,
|
messagingDeliverJob,
|
||||||
routineJobKey,
|
routineJobKey,
|
||||||
routineWakeupJob,
|
routineWakeupJob,
|
||||||
@ -29,7 +27,6 @@ import type {
|
|||||||
MemoryProviderResolver,
|
MemoryProviderResolver,
|
||||||
PiOAuthLogins,
|
PiOAuthLogins,
|
||||||
RemoteConnectorDependencies,
|
RemoteConnectorDependencies,
|
||||||
VapidKeys,
|
|
||||||
} from "@rakazo/adapters";
|
} from "@rakazo/adapters";
|
||||||
import {
|
import {
|
||||||
acquireComputerExecutionLease,
|
acquireComputerExecutionLease,
|
||||||
@ -46,6 +43,7 @@ import {
|
|||||||
computerSupportsUpdate,
|
computerSupportsUpdate,
|
||||||
computerUpdateView,
|
computerUpdateView,
|
||||||
createVoiceProvider,
|
createVoiceProvider,
|
||||||
|
deletePushToken,
|
||||||
deploymentAutoReviewDefault,
|
deploymentAutoReviewDefault,
|
||||||
destroyBot,
|
destroyBot,
|
||||||
displayBotWorkspacePath,
|
displayBotWorkspacePath,
|
||||||
@ -54,7 +52,6 @@ import {
|
|||||||
filterTeamWorkspaceEntries,
|
filterTeamWorkspaceEntries,
|
||||||
hasActiveComputerControl,
|
hasActiveComputerControl,
|
||||||
isAutoReviewCheckerConfigured,
|
isAutoReviewCheckerConfigured,
|
||||||
isBlockedHostname,
|
|
||||||
isComputerScreenUnavailable,
|
isComputerScreenUnavailable,
|
||||||
isProjectWorkspacePath,
|
isProjectWorkspacePath,
|
||||||
isSandboxGoneError,
|
isSandboxGoneError,
|
||||||
@ -77,6 +74,7 @@ import {
|
|||||||
resolveAutoReviewChecker,
|
resolveAutoReviewChecker,
|
||||||
resolveBotWorkspacePath,
|
resolveBotWorkspacePath,
|
||||||
sanitizeComposioError,
|
sanitizeComposioError,
|
||||||
|
savePushToken,
|
||||||
scheduleComputerControlExpiry,
|
scheduleComputerControlExpiry,
|
||||||
scheduleComputerSleep,
|
scheduleComputerSleep,
|
||||||
screenLeaseIdForRun,
|
screenLeaseIdForRun,
|
||||||
@ -90,18 +88,9 @@ import {
|
|||||||
verifyMcpInstall,
|
verifyMcpInstall,
|
||||||
} from "@rakazo/adapters";
|
} from "@rakazo/adapters";
|
||||||
import type { Auth } from "@rakazo/auth";
|
import type { Auth } from "@rakazo/auth";
|
||||||
import type {
|
import type { Actor, ComputerStatus, McpServer, Me, SpaceNavigation } from "@rakazo/contracts";
|
||||||
Actor,
|
|
||||||
ComputerStatus,
|
|
||||||
McpServer,
|
|
||||||
Me,
|
|
||||||
SpaceBot,
|
|
||||||
SpaceGroup,
|
|
||||||
SpaceNavigation,
|
|
||||||
} from "@rakazo/contracts";
|
|
||||||
import {
|
import {
|
||||||
appContract,
|
appContract,
|
||||||
CoachRoutineKeySchema,
|
|
||||||
IntegrationProviderIdSchema,
|
IntegrationProviderIdSchema,
|
||||||
OPENAI_COMPATIBLE_PROVIDER_ID,
|
OPENAI_COMPATIBLE_PROVIDER_ID,
|
||||||
} from "@rakazo/contracts";
|
} from "@rakazo/contracts";
|
||||||
@ -134,7 +123,6 @@ import {
|
|||||||
findModelCredential,
|
findModelCredential,
|
||||||
findSpaceModelTier,
|
findSpaceModelTier,
|
||||||
formatMessagingLinkCode,
|
formatMessagingLinkCode,
|
||||||
getNotificationSettings,
|
|
||||||
InvalidProjectWorkspacePathError,
|
InvalidProjectWorkspacePathError,
|
||||||
InvalidSpaceNameError,
|
InvalidSpaceNameError,
|
||||||
IsolationError,
|
IsolationError,
|
||||||
@ -143,25 +131,19 @@ import {
|
|||||||
listSpaceUserBotIds,
|
listSpaceUserBotIds,
|
||||||
newestModelCredentialOrder,
|
newestModelCredentialOrder,
|
||||||
newestVoiceCredentialOrder,
|
newestVoiceCredentialOrder,
|
||||||
ownMainAgentsBlockingSpaceDeletion,
|
|
||||||
Prisma,
|
Prisma,
|
||||||
parseComputerMode,
|
parseComputerMode,
|
||||||
projectWorkspaceDto,
|
projectWorkspaceDto,
|
||||||
releaseSpaceDeletionClaim,
|
releaseSpaceDeletionClaim,
|
||||||
removePushSubscription,
|
|
||||||
removePushSubscriptionsOfKind,
|
|
||||||
renewSpaceDeletionClaim,
|
renewSpaceDeletionClaim,
|
||||||
requireMembership,
|
requireMembership,
|
||||||
resolveProjectWorkspaceDirectory,
|
|
||||||
SPACE_DELETION_CLAIM_TIMEOUT_MS,
|
SPACE_DELETION_CLAIM_TIMEOUT_MS,
|
||||||
SpaceDeletionInProgressError,
|
SpaceDeletionInProgressError,
|
||||||
SpaceLimitError,
|
SpaceLimitError,
|
||||||
SpaceNotEmptyError,
|
SpaceNotEmptyError,
|
||||||
SpaceNotFoundError,
|
SpaceNotFoundError,
|
||||||
savePushSubscription,
|
|
||||||
selectSpaceModelPreference,
|
selectSpaceModelPreference,
|
||||||
selectSpaceVoicePreference,
|
selectSpaceVoicePreference,
|
||||||
updateNotificationSettings,
|
|
||||||
wireProjectWorkspace,
|
wireProjectWorkspace,
|
||||||
} from "@rakazo/db";
|
} from "@rakazo/db";
|
||||||
import { getLogger } from "@rakazo/logging";
|
import { getLogger } from "@rakazo/logging";
|
||||||
@ -169,15 +151,12 @@ import { deleteAgentSecret, listAgentSecrets, putAgentSecret } from "./agent-sec
|
|||||||
import { createAgentSkillsService } from "./agent-skills.js";
|
import { createAgentSkillsService } from "./agent-skills.js";
|
||||||
import { aiConsentStatus, allowAiConsent } from "./ai-consent.js";
|
import { aiConsentStatus, allowAiConsent } from "./ai-consent.js";
|
||||||
import { createOwnedArtifact, getOwnedArtifact, getSpaceArtifact } from "./artifacts.js";
|
import { createOwnedArtifact, getOwnedArtifact, getSpaceArtifact } from "./artifacts.js";
|
||||||
import { importableRoutines, relativeImportedFiles, safeImportPath } from "./bot-import.js";
|
|
||||||
import { botProfileLabelsChanged, commitBotUpdate } from "./bot-update.js";
|
import { botProfileLabelsChanged, commitBotUpdate } from "./bot-update.js";
|
||||||
import { createCoachService } from "./coach.js";
|
|
||||||
import {
|
import {
|
||||||
executionBlocksUserTakeover,
|
executionBlocksUserTakeover,
|
||||||
resolveBusyBotName,
|
resolveBusyBotName,
|
||||||
toComputerStatus,
|
toComputerStatus,
|
||||||
} from "./computer-status.js";
|
} from "./computer-status.js";
|
||||||
import { recordConnectedApp } from "./connected-app-facts.js";
|
|
||||||
import { listInbox } from "./inbox.js";
|
import { listInbox } from "./inbox.js";
|
||||||
import { searchIntegrationCatalog } from "./integration-catalog.js";
|
import { searchIntegrationCatalog } from "./integration-catalog.js";
|
||||||
import {
|
import {
|
||||||
@ -187,14 +166,6 @@ import {
|
|||||||
renameMainConversation,
|
renameMainConversation,
|
||||||
} from "./main-conversation.js";
|
} from "./main-conversation.js";
|
||||||
import { buildMcpUpdateMaterial } from "./mcp-material.js";
|
import { buildMcpUpdateMaterial } from "./mcp-material.js";
|
||||||
import {
|
|
||||||
cancelMediaJob,
|
|
||||||
getMediaCapabilities,
|
|
||||||
getMediaJob,
|
|
||||||
getMediaSettings,
|
|
||||||
startMediaJob,
|
|
||||||
updateMediaSettings,
|
|
||||||
} from "./media-jobs.js";
|
|
||||||
import {
|
import {
|
||||||
disconnectMemoryProvider,
|
disconnectMemoryProvider,
|
||||||
getMemoryProviderConfig,
|
getMemoryProviderConfig,
|
||||||
@ -209,12 +180,7 @@ import {
|
|||||||
promptFocus,
|
promptFocus,
|
||||||
startOnboarding,
|
startOnboarding,
|
||||||
} from "./onboarding.js";
|
} from "./onboarding.js";
|
||||||
import {
|
import { getScanStatus, startOnboardingScan } from "./onboarding-scan.js";
|
||||||
applyOnboardingScan,
|
|
||||||
onboardingScanFolder,
|
|
||||||
onboardingScanStatus,
|
|
||||||
startOnboardingScan,
|
|
||||||
} from "./onboarding-scan.js";
|
|
||||||
import { listSpaceRuns } from "./runs.js";
|
import { listSpaceRuns } from "./runs.js";
|
||||||
import { addScreenProxyCapability } from "./screen-proxy.js";
|
import { addScreenProxyCapability } from "./screen-proxy.js";
|
||||||
import { querySpaceSearch } from "./search.js";
|
import { querySpaceSearch } from "./search.js";
|
||||||
@ -255,7 +221,6 @@ import {
|
|||||||
listVoiceCatalog,
|
listVoiceCatalog,
|
||||||
loadDefaultVoiceCredential,
|
loadDefaultVoiceCredential,
|
||||||
loadVoiceCredential,
|
loadVoiceCredential,
|
||||||
mediaTranscriptionSettings,
|
|
||||||
persistVoiceCredential,
|
persistVoiceCredential,
|
||||||
prepareVoice,
|
prepareVoice,
|
||||||
toVoiceCredential,
|
toVoiceCredential,
|
||||||
@ -320,11 +285,6 @@ async function reconcilePendingConnections(
|
|||||||
: []),
|
: []),
|
||||||
];
|
];
|
||||||
if (updates.length > 0) await prisma.$transaction(updates);
|
if (updates.length > 0) await prisma.$transaction(updates);
|
||||||
const connected = new Set(sync.connectIds);
|
|
||||||
for (const row of rows) {
|
|
||||||
if (row.status !== "pending" || !connected.has(row.id)) continue;
|
|
||||||
await recordConnectedApp(prisma, { userId: owner.userId, connectorId, provider: row.provider });
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Serialize begin/revoke for one user+provider so slug-wide remote deletes cannot race a new connect. */
|
/** Serialize begin/revoke for one user+provider so slug-wide remote deletes cannot race a new connect. */
|
||||||
@ -486,7 +446,6 @@ function mcpAssignmentDto(row: {
|
|||||||
|
|
||||||
export interface RouterDeps {
|
export interface RouterDeps {
|
||||||
cloudAgent?: CloudAgentConnection | null;
|
cloudAgent?: CloudAgentConnection | null;
|
||||||
media?: MediaRuntime;
|
|
||||||
prisma: PrismaClient;
|
prisma: PrismaClient;
|
||||||
events: ThreadEvents;
|
events: ThreadEvents;
|
||||||
auth: Auth;
|
auth: Auth;
|
||||||
@ -498,8 +457,6 @@ export interface RouterDeps {
|
|||||||
secrets: EncryptedSecretStore;
|
secrets: EncryptedSecretStore;
|
||||||
oauthLogins: PiOAuthLogins;
|
oauthLogins: PiOAuthLogins;
|
||||||
integrationSettings?: IntegrationProviderSettings;
|
integrationSettings?: IntegrationProviderSettings;
|
||||||
/** The deployment's web push key pair; without it browsers cannot subscribe. */
|
|
||||||
vapid?: Pick<VapidKeys, "publicKey">;
|
|
||||||
composio?: ComposioProvider;
|
composio?: ComposioProvider;
|
||||||
mcpOAuth?: McpOAuthBroker;
|
mcpOAuth?: McpOAuthBroker;
|
||||||
connectors: ConnectorRegistry;
|
connectors: ConnectorRegistry;
|
||||||
@ -533,26 +490,6 @@ export interface RouterDeps {
|
|||||||
* keep the deletion claim renewed forever and block stale-claim recovery. */
|
* keep the deletion claim renewed forever and block stale-claim recovery. */
|
||||||
const SPACE_TEARDOWN_TIMEOUT_MS = 120_000;
|
const SPACE_TEARDOWN_TIMEOUT_MS = 120_000;
|
||||||
|
|
||||||
async function removeGroupWithArtifacts(
|
|
||||||
deps: RouterDeps,
|
|
||||||
groupRepos: ReturnType<typeof createGroupRepos>,
|
|
||||||
actor: Actor,
|
|
||||||
groupId: string,
|
|
||||||
) {
|
|
||||||
const removed = await groupRepos.removeGroup(actor, groupId);
|
|
||||||
const cleanup = await Promise.allSettled(
|
|
||||||
removed.artifactStorageKeys.map((storageKey) =>
|
|
||||||
deps.artifacts.remove(
|
|
||||||
storageKey,
|
|
||||||
computerContext(actor, removed.contextBotId, `group-remove:${groupId}`),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
for (const result of cleanup) {
|
|
||||||
if (result.status === "rejected") getLogger().error("group artifact cleanup", result.reason);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function spaceTeardownTimeoutMs(): number {
|
function spaceTeardownTimeoutMs(): number {
|
||||||
const override = Number(process.env.SPACE_TEARDOWN_TIMEOUT_MS ?? "");
|
const override = Number(process.env.SPACE_TEARDOWN_TIMEOUT_MS ?? "");
|
||||||
return Number.isFinite(override) && override > 0 ? override : SPACE_TEARDOWN_TIMEOUT_MS;
|
return Number.isFinite(override) && override > 0 ? override : SPACE_TEARDOWN_TIMEOUT_MS;
|
||||||
@ -567,18 +504,9 @@ function mapSpaceLifecycleError(error: unknown): unknown {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function createRouter(deps: RouterDeps) {
|
export function createRouter(deps: RouterDeps) {
|
||||||
const os = implement(appContract).$context<{
|
const os = implement(appContract).$context<{ actor: Actor | null; signal?: AbortSignal }>();
|
||||||
actor: Actor | null;
|
|
||||||
signal?: AbortSignal;
|
|
||||||
userAgent?: string;
|
|
||||||
}>();
|
|
||||||
const repos = createRepos(deps.prisma);
|
const repos = createRepos(deps.prisma);
|
||||||
const onboardingDeps = {
|
const onboardingDeps = { prisma: deps.prisma, events: deps.events, connectors: deps.connectors };
|
||||||
prisma: deps.prisma,
|
|
||||||
events: deps.events,
|
|
||||||
connectors: deps.connectors,
|
|
||||||
jobs: deps.jobs,
|
|
||||||
};
|
|
||||||
const mcpOAuth = deps.mcpOAuth ?? new McpOAuthBroker(deps.prisma, deps.secrets);
|
const mcpOAuth = deps.mcpOAuth ?? new McpOAuthBroker(deps.prisma, deps.secrets);
|
||||||
const groupRepos = createGroupRepos(deps.prisma);
|
const groupRepos = createGroupRepos(deps.prisma);
|
||||||
const projectGoalRepos = createProjectGoalRepos(deps.prisma);
|
const projectGoalRepos = createProjectGoalRepos(deps.prisma);
|
||||||
@ -591,7 +519,6 @@ export function createRouter(deps: RouterDeps) {
|
|||||||
dataDir: deps.dataDir,
|
dataDir: deps.dataDir,
|
||||||
});
|
});
|
||||||
const agentSkills = createAgentSkillsService(deps.prisma);
|
const agentSkills = createAgentSkillsService(deps.prisma);
|
||||||
const coach = createCoachService(deps);
|
|
||||||
|
|
||||||
const authed = os.use(async ({ context, next }) => {
|
const authed = os.use(async ({ context, next }) => {
|
||||||
if (!context.actor) throw new ORPCError("UNAUTHORIZED");
|
if (!context.actor) throw new ORPCError("UNAUTHORIZED");
|
||||||
@ -631,78 +558,17 @@ export function createRouter(deps: RouterDeps) {
|
|||||||
profile: {
|
profile: {
|
||||||
get: authed.profile.get.handler(({ context }) => getProfile(deps.prisma, context.actor)),
|
get: authed.profile.get.handler(({ context }) => getProfile(deps.prisma, context.actor)),
|
||||||
update: authed.profile.update.handler(({ context, input }) =>
|
update: authed.profile.update.handler(({ context, input }) =>
|
||||||
updateProfile(deps, context.actor, input),
|
updateProfile(deps.prisma, context.actor, input),
|
||||||
),
|
),
|
||||||
forget: authed.profile.forget.handler(({ context, input }) =>
|
forget: authed.profile.forget.handler(({ context, input }) =>
|
||||||
forgetProfile(deps, context.actor, input),
|
forgetProfile(deps.prisma, context.actor, input),
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
coach: {
|
|
||||||
today: authed.coach.today.handler(({ context }) => coach.today(context.actor)),
|
|
||||||
goals: {
|
|
||||||
list: authed.coach.goals.list.handler(({ context, input }) =>
|
|
||||||
coach.listGoals(context.actor, input),
|
|
||||||
),
|
|
||||||
create: authed.coach.goals.create.handler(({ context, input }) =>
|
|
||||||
coach.createGoal(context.actor, input),
|
|
||||||
),
|
|
||||||
update: authed.coach.goals.update.handler(({ context, input }) =>
|
|
||||||
coach.updateGoal(context.actor, input),
|
|
||||||
),
|
|
||||||
remove: authed.coach.goals.remove.handler(({ context, input }) =>
|
|
||||||
coach.removeGoal(context.actor, input),
|
|
||||||
),
|
|
||||||
},
|
|
||||||
habits: {
|
|
||||||
list: authed.coach.habits.list.handler(({ context, input }) =>
|
|
||||||
coach.listHabits(context.actor, input),
|
|
||||||
),
|
|
||||||
create: authed.coach.habits.create.handler(({ context, input }) =>
|
|
||||||
coach.createHabit(context.actor, input),
|
|
||||||
),
|
|
||||||
update: authed.coach.habits.update.handler(({ context, input }) =>
|
|
||||||
coach.updateHabit(context.actor, input),
|
|
||||||
),
|
|
||||||
remove: authed.coach.habits.remove.handler(({ context, input }) =>
|
|
||||||
coach.removeHabit(context.actor, input),
|
|
||||||
),
|
|
||||||
},
|
|
||||||
checkIns: {
|
|
||||||
log: authed.coach.checkIns.log.handler(({ context, input }) =>
|
|
||||||
coach.logCheckIn(context.actor, input),
|
|
||||||
),
|
|
||||||
list: authed.coach.checkIns.list.handler(({ context, input }) =>
|
|
||||||
coach.listCheckIns(context.actor, input),
|
|
||||||
),
|
|
||||||
},
|
|
||||||
rhythm: {
|
|
||||||
get: authed.coach.rhythm.get.handler(({ context }) => coach.getRhythm(context.actor)),
|
|
||||||
set: authed.coach.rhythm.set.handler(({ context, input }) =>
|
|
||||||
coach.setRhythm(context.actor, input),
|
|
||||||
),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
spaces: {
|
spaces: {
|
||||||
list: authed.spaces.list.handler(async ({ context }) =>
|
list: authed.spaces.list.handler(async ({ context }) =>
|
||||||
spaceNavigationDto(deps, context.actor, repos, groupRepos),
|
spaceNavigationDto(deps, context.actor, repos, groupRepos),
|
||||||
),
|
),
|
||||||
create: authed.spaces.create.handler(async ({ context, input }) => {
|
create: authed.spaces.create.handler(async ({ context, input }) => {
|
||||||
// A linked folder lives on the host, and the host is the deployment owner's machine.
|
|
||||||
if (input.workspacePath && !context.actor.isDeploymentOwner) {
|
|
||||||
throw new ORPCError("FORBIDDEN");
|
|
||||||
}
|
|
||||||
// Check the folder before the space exists, so a bad path leaves no space behind.
|
|
||||||
let workspacePath: string | null = null;
|
|
||||||
if (input.workspacePath) {
|
|
||||||
try {
|
|
||||||
workspacePath = await resolveProjectWorkspaceDirectory(input.workspacePath);
|
|
||||||
} catch (error) {
|
|
||||||
if (error instanceof InvalidProjectWorkspacePathError) {
|
|
||||||
throw new ORPCError("BAD_REQUEST", { message: error.message });
|
|
||||||
}
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
let space: { id: string; name: string };
|
let space: { id: string; name: string };
|
||||||
try {
|
try {
|
||||||
space = await createSpaceForMember(deps.prisma, {
|
space = await createSpaceForMember(deps.prisma, {
|
||||||
@ -716,16 +582,20 @@ export function createRouter(deps: RouterDeps) {
|
|||||||
}
|
}
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
const workspace = workspacePath
|
let workspace = null;
|
||||||
? await wireProjectWorkspace(deps.prisma, { spaceId: space.id, workspacePath })
|
if (input.workspacePath) {
|
||||||
: null;
|
try {
|
||||||
// A project starts as a conversation with its own main agent.
|
workspace = await wireProjectWorkspace(deps.prisma, {
|
||||||
await ensureMainConversation(deps.prisma, context.actor.userId, space.id);
|
spaceId: space.id,
|
||||||
const spaceActor = await requireMembership(deps.prisma, context.actor.userId, space.id);
|
workspacePath: input.workspacePath,
|
||||||
const [bots, groups] = await Promise.all([
|
});
|
||||||
repos.listBots(spaceActor),
|
} catch (error) {
|
||||||
groupRepos.listGroups(spaceActor),
|
if (error instanceof InvalidProjectWorkspacePathError) {
|
||||||
]);
|
throw new ORPCError("BAD_REQUEST", { message: error.message });
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
return {
|
return {
|
||||||
id: space.id,
|
id: space.id,
|
||||||
name: space.name,
|
name: space.name,
|
||||||
@ -733,26 +603,12 @@ export function createRouter(deps: RouterDeps) {
|
|||||||
hasContent: false,
|
hasContent: false,
|
||||||
canDelete: true,
|
canDelete: true,
|
||||||
workspace,
|
workspace,
|
||||||
bots: bots.map(spaceBotDto),
|
bots: [],
|
||||||
groups: groups.map(spaceGroupDto),
|
groups: [],
|
||||||
externalConversations: [],
|
externalConversations: [],
|
||||||
botSections: [],
|
botSections: [],
|
||||||
};
|
};
|
||||||
}),
|
}),
|
||||||
ensureMain: authed.spaces.ensureMain.handler(async ({ context, input }) => {
|
|
||||||
const conversation = await ensureMainConversation(
|
|
||||||
deps.prisma,
|
|
||||||
context.actor.userId,
|
|
||||||
input.spaceId,
|
|
||||||
).catch((error: unknown) => {
|
|
||||||
throw mapSpaceLifecycleError(error);
|
|
||||||
});
|
|
||||||
return {
|
|
||||||
botId: conversation.leadBotId,
|
|
||||||
groupId: conversation.groupId,
|
|
||||||
spaceId: conversation.spaceId,
|
|
||||||
};
|
|
||||||
}),
|
|
||||||
remove: authed.spaces.remove.handler(async ({ context, input }) => {
|
remove: authed.spaces.remove.handler(async ({ context, input }) => {
|
||||||
let claimId: string | null = null;
|
let claimId: string | null = null;
|
||||||
let claimActive = false;
|
let claimActive = false;
|
||||||
@ -765,41 +621,6 @@ export function createRouter(deps: RouterDeps) {
|
|||||||
spaceId: input.spaceId,
|
spaceId: input.spaceId,
|
||||||
};
|
};
|
||||||
try {
|
try {
|
||||||
// The project's own main agent goes with the project; anything else must be
|
|
||||||
// cleared first.
|
|
||||||
const ownMain = await ownMainAgentsBlockingSpaceDeletion(deps.prisma, deleteInput);
|
|
||||||
if (ownMain.groupIds.length > 0 || ownMain.botIds.length > 0) {
|
|
||||||
const spaceActor = await requireMembership(
|
|
||||||
deps.prisma,
|
|
||||||
context.actor.userId,
|
|
||||||
input.spaceId,
|
|
||||||
);
|
|
||||||
for (const groupId of ownMain.groupIds) {
|
|
||||||
await removeGroupWithArtifacts(deps, groupRepos, spaceActor, groupId);
|
|
||||||
}
|
|
||||||
for (const botId of ownMain.botIds) {
|
|
||||||
const bot = await repos.getBot(spaceActor, botId, { includeArchived: true });
|
|
||||||
await destroyBot(
|
|
||||||
{
|
|
||||||
prisma: deps.prisma,
|
|
||||||
sandbox: deps.sandbox,
|
|
||||||
home: deps.home,
|
|
||||||
jobs: deps.jobs,
|
|
||||||
artifacts: deps.artifacts,
|
|
||||||
dataDir: deps.dataDir,
|
|
||||||
},
|
|
||||||
bot,
|
|
||||||
{
|
|
||||||
operationId: "spaces.remove",
|
|
||||||
traceId: "spaces.remove",
|
|
||||||
spaceId: input.spaceId,
|
|
||||||
userId: context.actor.userId,
|
|
||||||
signal: new AbortController().signal,
|
|
||||||
},
|
|
||||||
{ deleteMemories: true },
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Claim emptiness before external teardown. Bot and group creation
|
// Claim emptiness before external teardown. Bot and group creation
|
||||||
// take the same lifecycle lock and reject the Space until deletion
|
// take the same lifecycle lock and reject the Space until deletion
|
||||||
// finishes or this claim is released.
|
// finishes or this claim is released.
|
||||||
@ -1265,87 +1086,6 @@ export function createRouter(deps: RouterDeps) {
|
|||||||
}
|
}
|
||||||
return duplicate;
|
return duplicate;
|
||||||
}),
|
}),
|
||||||
import: authed.bots.import.handler(async ({ context, input }) => {
|
|
||||||
const files = relativeImportedFiles(input.files);
|
|
||||||
const memory = input.memory.flatMap((doc) => {
|
|
||||||
const path = safeImportPath(doc.path);
|
|
||||||
return path ? [{ path, content: doc.content }] : [];
|
|
||||||
});
|
|
||||||
if (!files || memory.length !== input.memory.length) {
|
|
||||||
throw new ORPCError("BAD_REQUEST", { message: "This export has an unsafe file path." });
|
|
||||||
}
|
|
||||||
const routines = importableRoutines(input.routines);
|
|
||||||
for (const routine of routines) {
|
|
||||||
if (hasMixedOneShotSchedule(routine.crons)) {
|
|
||||||
throw new ORPCError("BAD_REQUEST", {
|
|
||||||
message: "A one-time schedule can't be combined with other schedules.",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
if (routine.crons.length > 0) nextRoutineDate(routine.crons, routine.timezone);
|
|
||||||
}
|
|
||||||
const created = await repos
|
|
||||||
.createBot(context.actor, { ...input.bot, notifyOnFinish: true })
|
|
||||||
.catch((error: unknown) => {
|
|
||||||
throw mapSpaceLifecycleError(error);
|
|
||||||
});
|
|
||||||
const bot = await repos.getBot(context.actor, created.id);
|
|
||||||
const importContext = {
|
|
||||||
operationId: "import",
|
|
||||||
traceId: "import",
|
|
||||||
spaceId: context.actor.spaceId,
|
|
||||||
userId: context.actor.userId,
|
|
||||||
signal: new AbortController().signal,
|
|
||||||
};
|
|
||||||
try {
|
|
||||||
if (!bot.computer) throw new IsolationError();
|
|
||||||
for (const doc of memory) {
|
|
||||||
await deps.memory.commit(
|
|
||||||
{ scope: "bot", botId: bot.id, path: doc.path, content: doc.content },
|
|
||||||
importContext,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
const prefix = bot.computer.scope === "team" ? teamBotWorkspaceDirectory(bot.id) : "";
|
|
||||||
for (const file of files) {
|
|
||||||
await deps.home.writeFile(
|
|
||||||
bot.computer.homeKey,
|
|
||||||
prefix ? `${prefix}/${file.path}` : file.path,
|
|
||||||
file.content,
|
|
||||||
importContext,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
// Imported schedules start paused: the user decides when this bot may act on its own.
|
|
||||||
if (routines.length) {
|
|
||||||
await deps.prisma.routine.createMany({
|
|
||||||
data: routines.map((routine) => ({
|
|
||||||
spaceId: context.actor.spaceId,
|
|
||||||
botId: bot.id,
|
|
||||||
userId: context.actor.userId,
|
|
||||||
name: routine.name,
|
|
||||||
prompt: routine.prompt,
|
|
||||||
crons: routine.crons,
|
|
||||||
timezone: routine.timezone,
|
|
||||||
active: false,
|
|
||||||
})),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
await destroyBot(
|
|
||||||
{
|
|
||||||
prisma: deps.prisma,
|
|
||||||
sandbox: deps.sandbox,
|
|
||||||
home: deps.home,
|
|
||||||
jobs: deps.jobs,
|
|
||||||
artifacts: deps.artifacts,
|
|
||||||
dataDir: deps.dataDir,
|
|
||||||
},
|
|
||||||
bot,
|
|
||||||
{ ...importContext, operationId: "destroy", traceId: "destroy" },
|
|
||||||
{ deleteMemories: true },
|
|
||||||
).catch(() => undefined);
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
return created;
|
|
||||||
}),
|
|
||||||
reorder: authed.bots.reorder.handler(async ({ context, input }) => {
|
reorder: authed.bots.reorder.handler(async ({ context, input }) => {
|
||||||
await repos.reorderBots(context.actor, input.botIds);
|
await repos.reorderBots(context.actor, input.botIds);
|
||||||
return { ok: true as const };
|
return { ok: true as const };
|
||||||
@ -1479,11 +1219,7 @@ export function createRouter(deps: RouterDeps) {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
if (existing.kind === "main" && input.name !== undefined && input.name !== existing.name) {
|
if (existing.kind === "main" && input.name !== undefined && input.name !== existing.name) {
|
||||||
await renameMainConversation(
|
await renameMainConversation(deps.prisma, context.actor.userId, input.name);
|
||||||
deps.prisma,
|
|
||||||
{ userId: context.actor.userId, spaceId: existing.spaceId },
|
|
||||||
input.name,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
const bots = await repos.listBots(context.actor);
|
const bots = await repos.listBots(context.actor);
|
||||||
const bot = bots.find((b) => b.id === input.botId);
|
const bot = bots.find((b) => b.id === input.botId);
|
||||||
@ -1771,7 +1507,19 @@ export function createRouter(deps: RouterDeps) {
|
|||||||
input.groupId,
|
input.groupId,
|
||||||
"The main conversation can't be deleted.",
|
"The main conversation can't be deleted.",
|
||||||
);
|
);
|
||||||
await removeGroupWithArtifacts(deps, groupRepos, context.actor, input.groupId);
|
const removed = await groupRepos.removeGroup(context.actor, input.groupId);
|
||||||
|
const cleanup = await Promise.allSettled(
|
||||||
|
removed.artifactStorageKeys.map((storageKey) =>
|
||||||
|
deps.artifacts.remove(
|
||||||
|
storageKey,
|
||||||
|
computerContext(context.actor, removed.contextBotId, `group-remove:${input.groupId}`),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
for (const result of cleanup) {
|
||||||
|
if (result.status === "rejected")
|
||||||
|
getLogger().error("group artifact cleanup", result.reason);
|
||||||
|
}
|
||||||
return { ok: true as const };
|
return { ok: true as const };
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
@ -2482,7 +2230,10 @@ export function createRouter(deps: RouterDeps) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (isProjectWorkspacePath(storedPath)) {
|
if (isProjectWorkspacePath(storedPath)) {
|
||||||
const workspaceRoot = await requireProjectWorkspaceRoot(deps.prisma, context.actor);
|
const workspaceRoot = await requireProjectWorkspaceRoot(
|
||||||
|
deps.prisma,
|
||||||
|
context.actor.spaceId,
|
||||||
|
);
|
||||||
const entries = await listProjectWorkspaceFiles(workspaceRoot, storedPath);
|
const entries = await listProjectWorkspaceFiles(workspaceRoot, storedPath);
|
||||||
return entries.map((entry) => ({
|
return entries.map((entry) => ({
|
||||||
...entry,
|
...entry,
|
||||||
@ -2530,7 +2281,10 @@ export function createRouter(deps: RouterDeps) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (isProjectWorkspacePath(storedPath)) {
|
if (isProjectWorkspacePath(storedPath)) {
|
||||||
const workspaceRoot = await requireProjectWorkspaceRoot(deps.prisma, context.actor);
|
const workspaceRoot = await requireProjectWorkspaceRoot(
|
||||||
|
deps.prisma,
|
||||||
|
context.actor.spaceId,
|
||||||
|
);
|
||||||
const bytes = await readProjectWorkspaceFile(workspaceRoot, storedPath, {
|
const bytes = await readProjectWorkspaceFile(workspaceRoot, storedPath, {
|
||||||
maxBytes: MAX_COMPUTER_TEXT_FILE_BYTES,
|
maxBytes: MAX_COMPUTER_TEXT_FILE_BYTES,
|
||||||
});
|
});
|
||||||
@ -2673,28 +2427,21 @@ export function createRouter(deps: RouterDeps) {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
if (!doc) throw new IsolationError();
|
if (!doc) throw new IsolationError();
|
||||||
const updated = await deps.memory
|
const updated = await deps.memory.commit(
|
||||||
.commit(
|
{
|
||||||
{
|
scope: doc.scope as "bot" | "user",
|
||||||
scope: doc.scope as "bot" | "user",
|
botId: doc.botId ?? undefined,
|
||||||
botId: doc.botId ?? undefined,
|
path: doc.path,
|
||||||
path: doc.path,
|
content: input.content,
|
||||||
content: input.content,
|
},
|
||||||
},
|
{
|
||||||
{
|
operationId: "mem",
|
||||||
operationId: "mem",
|
traceId: "mem",
|
||||||
traceId: "mem",
|
spaceId: context.actor.spaceId,
|
||||||
spaceId: context.actor.spaceId,
|
userId: context.actor.userId,
|
||||||
userId: context.actor.userId,
|
signal: new AbortController().signal,
|
||||||
signal: new AbortController().signal,
|
},
|
||||||
},
|
);
|
||||||
)
|
|
||||||
.catch((error: unknown) => {
|
|
||||||
if (error instanceof MemoryDocumentTooLargeError) {
|
|
||||||
throw new ORPCError("BAD_REQUEST", { message: error.message });
|
|
||||||
}
|
|
||||||
throw error;
|
|
||||||
});
|
|
||||||
return {
|
return {
|
||||||
id: updated.id,
|
id: updated.id,
|
||||||
scope: doc.scope as "bot" | "user",
|
scope: doc.scope as "bot" | "user",
|
||||||
@ -3670,7 +3417,7 @@ export function createRouter(deps: RouterDeps) {
|
|||||||
return { ok: true as const };
|
return { ok: true as const };
|
||||||
}),
|
}),
|
||||||
choose: authed.onboarding.choose.handler(async ({ context, input }) => {
|
choose: authed.onboarding.choose.handler(async ({ context, input }) => {
|
||||||
await chooseFocus(onboardingDeps, context.actor, input.botId, input);
|
await chooseFocus(onboardingDeps, context.actor, input.botId, input.optionId);
|
||||||
return { ok: true as const };
|
return { ok: true as const };
|
||||||
}),
|
}),
|
||||||
dismissFocus: authed.onboarding.dismissFocus.handler(async ({ context, input }) => {
|
dismissFocus: authed.onboarding.dismissFocus.handler(async ({ context, input }) => {
|
||||||
@ -3687,18 +3434,16 @@ export function createRouter(deps: RouterDeps) {
|
|||||||
);
|
);
|
||||||
return { ok: true as const };
|
return { ok: true as const };
|
||||||
}),
|
}),
|
||||||
scanTarget: authed.onboarding.scanTarget.handler(async ({ context }) => ({
|
scan: authed.onboarding.scan.handler(async ({ context, input }) => {
|
||||||
folder: await onboardingScanFolder(context.actor, deps.env.onboardingScanHome),
|
const { scanId } = await startOnboardingScan(
|
||||||
})),
|
{ actor: context.actor, botId: input.botId, scanHome: deps.env.onboardingScanHome },
|
||||||
scan: authed.onboarding.scan.handler(({ context }) =>
|
deps.prisma,
|
||||||
startOnboardingScan(context.actor, deps.env.onboardingScanHome),
|
);
|
||||||
),
|
return { scanId };
|
||||||
scanStatus: authed.onboarding.scanStatus.handler(async ({ context, input }) =>
|
}),
|
||||||
onboardingScanStatus(input.scanId, context.actor.userId),
|
scanStatus: authed.onboarding.scanStatus.handler(async ({ context, input }) => {
|
||||||
),
|
return await getScanStatus(input.scanId, context.actor.userId);
|
||||||
applyScan: authed.onboarding.applyScan.handler(({ context, input }) =>
|
}),
|
||||||
applyOnboardingScan(deps.prisma, context.actor, deps.env.onboardingScanHome, input),
|
|
||||||
),
|
|
||||||
},
|
},
|
||||||
integrationSetup: {
|
integrationSetup: {
|
||||||
get: authed.integrationSetup.get.handler(async ({ context }) => {
|
get: authed.integrationSetup.get.handler(async ({ context }) => {
|
||||||
@ -3736,30 +3481,6 @@ export function createRouter(deps: RouterDeps) {
|
|||||||
return { ok: true as const };
|
return { ok: true as const };
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
media: {
|
|
||||||
settings: {
|
|
||||||
get: authed.media.settings.get.handler(({ context }) =>
|
|
||||||
getMediaSettings(deps, context.actor),
|
|
||||||
),
|
|
||||||
update: authed.media.settings.update.handler(({ context, input }) =>
|
|
||||||
updateMediaSettings(deps, context.actor, input),
|
|
||||||
),
|
|
||||||
},
|
|
||||||
capabilities: authed.media.capabilities.handler(({ context, input }) =>
|
|
||||||
getMediaCapabilities(deps, context.actor, input),
|
|
||||||
),
|
|
||||||
jobs: {
|
|
||||||
start: authed.media.jobs.start.handler(({ context, input }) =>
|
|
||||||
startMediaJob(deps, context.actor, input),
|
|
||||||
),
|
|
||||||
get: authed.media.jobs.get.handler(({ context, input }) =>
|
|
||||||
getMediaJob(deps, context.actor, input.jobId),
|
|
||||||
),
|
|
||||||
cancel: authed.media.jobs.cancel.handler(({ context, input }) =>
|
|
||||||
cancelMediaJob(deps, context.actor, input.jobId),
|
|
||||||
),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
connections: {
|
connections: {
|
||||||
catalog: authed.connections.catalog.handler(async ({ context, input }) => {
|
catalog: authed.connections.catalog.handler(async ({ context, input }) => {
|
||||||
const adapterContext = connectionContext(
|
const adapterContext = connectionContext(
|
||||||
@ -3971,13 +3692,6 @@ export function createRouter(deps: RouterDeps) {
|
|||||||
}
|
}
|
||||||
throw new IsolationError();
|
throw new IsolationError();
|
||||||
}
|
}
|
||||||
if (!auth.authorizationUrl) {
|
|
||||||
await recordConnectedApp(deps.prisma, {
|
|
||||||
userId: context.actor.userId,
|
|
||||||
connectorId: input.connectorId,
|
|
||||||
provider: input.provider,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return { connectionId: row.id, authorizationUrl: auth.authorizationUrl };
|
return { connectionId: row.id, authorizationUrl: auth.authorizationUrl };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof IsolationError) throw error;
|
if (error instanceof IsolationError) throw error;
|
||||||
@ -4283,13 +3997,6 @@ export function createRouter(deps: RouterDeps) {
|
|||||||
},
|
},
|
||||||
{ timeout: 60_000 },
|
{ timeout: 60_000 },
|
||||||
);
|
);
|
||||||
if (row.status === "connected") {
|
|
||||||
await recordConnectedApp(deps.prisma, {
|
|
||||||
userId: context.actor.userId,
|
|
||||||
connectorId: row.connectorId,
|
|
||||||
provider: row.provider,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
id: row.id,
|
id: row.id,
|
||||||
@ -4933,26 +4640,24 @@ export function createRouter(deps: RouterDeps) {
|
|||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
get: authed.artifacts.get.handler(async ({ context, input }) => {
|
get: authed.artifacts.get.handler(async ({ context, input }) => {
|
||||||
|
if (input.groupId) {
|
||||||
|
const group = await groupRepos.getGroupTarget(context.actor, input.groupId);
|
||||||
|
const contextBotId = group.members[0]?.bot.id;
|
||||||
|
if (!contextBotId) throw new IsolationError();
|
||||||
|
return getSpaceArtifact(deps, context.actor, {
|
||||||
|
artifactId: input.artifactId,
|
||||||
|
groupId: input.groupId,
|
||||||
|
contextBotId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
await repos.getBot(context.actor, input.botId!);
|
||||||
try {
|
try {
|
||||||
if (input.groupId) {
|
|
||||||
const group = await groupRepos.getGroupTarget(context.actor, input.groupId);
|
|
||||||
const contextBotId = group.members[0]?.bot.id;
|
|
||||||
if (!contextBotId) throw new IsolationError();
|
|
||||||
return await getSpaceArtifact(deps, context.actor, {
|
|
||||||
artifactId: input.artifactId,
|
|
||||||
groupId: input.groupId,
|
|
||||||
contextBotId,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
await repos.getBot(context.actor, input.botId!);
|
|
||||||
return await getOwnedArtifact(deps, context.actor, {
|
return await getOwnedArtifact(deps, context.actor, {
|
||||||
botId: input.botId!,
|
botId: input.botId!,
|
||||||
artifactId: input.artifactId,
|
artifactId: input.artifactId,
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof AttachmentValidationError) {
|
if (error instanceof IsolationError) throw error;
|
||||||
throw new ORPCError("BAD_REQUEST", { message: error.message });
|
|
||||||
}
|
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
@ -5046,53 +4751,13 @@ export function createRouter(deps: RouterDeps) {
|
|||||||
},
|
},
|
||||||
notifications: {
|
notifications: {
|
||||||
registerPush: authed.notifications.registerPush.handler(async ({ context, input }) => {
|
registerPush: authed.notifications.registerPush.handler(async ({ context, input }) => {
|
||||||
await savePushSubscription(deps.prisma, context.actor.userId, {
|
await savePushToken(deps.dataDir, context.actor.userId, input.token);
|
||||||
kind: "expo",
|
|
||||||
endpoint: input.token,
|
|
||||||
userAgent: context.userAgent,
|
|
||||||
});
|
|
||||||
return { ok: true as const };
|
return { ok: true as const };
|
||||||
}),
|
}),
|
||||||
// The mobile app signs out without naming its token, so all of its tokens go.
|
|
||||||
unregisterPush: authed.notifications.unregisterPush.handler(async ({ context }) => {
|
unregisterPush: authed.notifications.unregisterPush.handler(async ({ context }) => {
|
||||||
await removePushSubscriptionsOfKind(deps.prisma, context.actor.userId, "expo");
|
await deletePushToken(deps.dataDir, context.actor.userId);
|
||||||
return { ok: true as const };
|
return { ok: true as const };
|
||||||
}),
|
}),
|
||||||
settings: {
|
|
||||||
get: authed.notifications.settings.get.handler(({ context }) =>
|
|
||||||
getNotificationSettings(deps.prisma, context.actor.userId),
|
|
||||||
),
|
|
||||||
update: authed.notifications.settings.update.handler(({ context, input }) =>
|
|
||||||
updateNotificationSettings(deps.prisma, context.actor.userId, input),
|
|
||||||
),
|
|
||||||
},
|
|
||||||
webPush: {
|
|
||||||
config: authed.notifications.webPush.config.handler(async () => {
|
|
||||||
const publicKey = (await deps.vapid?.publicKey()) ?? null;
|
|
||||||
return { available: publicKey !== null, publicKey };
|
|
||||||
}),
|
|
||||||
subscribe: authed.notifications.webPush.subscribe.handler(async ({ context, input }) => {
|
|
||||||
if (isBlockedHostname(new URL(input.endpoint).hostname.replace(/^\[|\]$/g, ""))) {
|
|
||||||
throw new ORPCError("BAD_REQUEST", {
|
|
||||||
message: "Endpoint must be a public push service",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
await savePushSubscription(deps.prisma, context.actor.userId, {
|
|
||||||
kind: "webpush",
|
|
||||||
endpoint: input.endpoint,
|
|
||||||
p256dh: input.keys.p256dh,
|
|
||||||
auth: input.keys.auth,
|
|
||||||
userAgent: context.userAgent,
|
|
||||||
});
|
|
||||||
return { ok: true as const };
|
|
||||||
}),
|
|
||||||
unsubscribe: authed.notifications.webPush.unsubscribe.handler(
|
|
||||||
async ({ context, input }) => {
|
|
||||||
await removePushSubscription(deps.prisma, context.actor.userId, input.endpoint);
|
|
||||||
return { ok: true as const };
|
|
||||||
},
|
|
||||||
),
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
search: {
|
search: {
|
||||||
query: authed.search.query.handler(async ({ context, input }) => ({
|
query: authed.search.query.handler(async ({ context, input }) => ({
|
||||||
@ -5117,11 +4782,8 @@ export function createRouter(deps: RouterDeps) {
|
|||||||
voice: {
|
voice: {
|
||||||
catalog: authed.voice.catalog.handler(async () => listVoiceCatalog()),
|
catalog: authed.voice.catalog.handler(async () => listVoiceCatalog()),
|
||||||
status: authed.voice.status.handler(async ({ context }) => {
|
status: authed.voice.status.handler(async ({ context }) => {
|
||||||
const [cred, server] = await Promise.all([
|
const cred = await findDefaultVoiceCredential(deps.prisma, context.actor);
|
||||||
findDefaultVoiceCredential(deps.prisma, context.actor),
|
return toVoiceStatus(cred);
|
||||||
mediaTranscriptionSettings(deps),
|
|
||||||
]);
|
|
||||||
return toVoiceStatus(cred, { mediaTranscription: Boolean(server) });
|
|
||||||
}),
|
}),
|
||||||
credentials: authed.voice.credentials.handler(async ({ context }) => {
|
credentials: authed.voice.credentials.handler(async ({ context }) => {
|
||||||
const rows = await deps.prisma.userVoiceCredential.findMany({
|
const rows = await deps.prisma.userVoiceCredential.findMany({
|
||||||
@ -5183,9 +4845,7 @@ export function createRouter(deps: RouterDeps) {
|
|||||||
{ isolationLevel: Prisma.TransactionIsolationLevel.Serializable },
|
{ isolationLevel: Prisma.TransactionIsolationLevel.Serializable },
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
return toVoiceStatus(cred, {
|
return toVoiceStatus(cred);
|
||||||
mediaTranscription: Boolean(await mediaTranscriptionSettings(deps)),
|
|
||||||
});
|
|
||||||
}),
|
}),
|
||||||
voices: authed.voice.voices.handler(async ({ context, input }) => {
|
voices: authed.voice.voices.handler(async ({ context, input }) => {
|
||||||
const loaded = await loadDefaultVoiceCredential(deps, context.actor);
|
const loaded = await loadDefaultVoiceCredential(deps, context.actor);
|
||||||
@ -5234,13 +4894,10 @@ function mapUpdaterError(error: unknown): never {
|
|||||||
|
|
||||||
async function requireProjectWorkspaceRoot(
|
async function requireProjectWorkspaceRoot(
|
||||||
prisma: Pick<PrismaClient, "space">,
|
prisma: Pick<PrismaClient, "space">,
|
||||||
actor: Actor,
|
spaceId: string,
|
||||||
): Promise<string> {
|
): Promise<string> {
|
||||||
// Only the deployment owner may link a host folder, so only they may browse one. This also
|
|
||||||
// closes folders that other members linked before linking was limited to the owner.
|
|
||||||
if (!actor.isDeploymentOwner) throw new ORPCError("FORBIDDEN");
|
|
||||||
const space = await prisma.space.findUnique({
|
const space = await prisma.space.findUnique({
|
||||||
where: { id: actor.spaceId },
|
where: { id: spaceId },
|
||||||
select: { workspacePath: true },
|
select: { workspacePath: true },
|
||||||
});
|
});
|
||||||
if (!space?.workspacePath) {
|
if (!space?.workspacePath) {
|
||||||
@ -5282,11 +4939,6 @@ async function spaceNavigationDto(
|
|||||||
orderBy: { createdAt: "asc" },
|
orderBy: { createdAt: "asc" },
|
||||||
});
|
});
|
||||||
const spaceIds = memberships.map((membership) => membership.spaceId);
|
const spaceIds = memberships.map((membership) => membership.spaceId);
|
||||||
const ownProjectMain = {
|
|
||||||
userId: actor.userId,
|
|
||||||
kind: "main",
|
|
||||||
space: { isDefault: false },
|
|
||||||
} as const;
|
|
||||||
const inactiveSpaceIds = spaceIds.filter((spaceId) => spaceId !== actor.spaceId);
|
const inactiveSpaceIds = spaceIds.filter((spaceId) => spaceId !== actor.spaceId);
|
||||||
const [
|
const [
|
||||||
currentBots,
|
currentBots,
|
||||||
@ -5307,14 +4959,13 @@ async function spaceNavigationDto(
|
|||||||
// Active-only navigation lists miss archived content in other spaces; count any
|
// Active-only navigation lists miss archived content in other spaces; count any
|
||||||
// bot/group in the actor's spaces (including another member's) so a shared
|
// bot/group in the actor's spaces (including another member's) so a shared
|
||||||
// non-empty space cannot look empty for onboarding redirects.
|
// non-empty space cannot look empty for onboarding redirects.
|
||||||
// The user's own main agent of a project is part of the project, not content to clear.
|
|
||||||
deps.prisma.bot.findMany({
|
deps.prisma.bot.findMany({
|
||||||
where: { spaceId: { in: spaceIds }, NOT: ownProjectMain },
|
where: { spaceId: { in: spaceIds } },
|
||||||
select: { spaceId: true },
|
select: { spaceId: true },
|
||||||
distinct: ["spaceId"],
|
distinct: ["spaceId"],
|
||||||
}),
|
}),
|
||||||
deps.prisma.chatGroup.findMany({
|
deps.prisma.chatGroup.findMany({
|
||||||
where: { spaceId: { in: spaceIds }, NOT: ownProjectMain },
|
where: { spaceId: { in: spaceIds } },
|
||||||
select: { spaceId: true },
|
select: { spaceId: true },
|
||||||
distinct: ["spaceId"],
|
distinct: ["spaceId"],
|
||||||
}),
|
}),
|
||||||
@ -5358,10 +5009,34 @@ async function spaceNavigationDto(
|
|||||||
memberships.length > 1 &&
|
memberships.length > 1 &&
|
||||||
!spacesWithContent.has(membership.spaceId) &&
|
!spacesWithContent.has(membership.spaceId) &&
|
||||||
(membership.space.deletingAt === null || membership.space.deletingAt < staleClaimBefore),
|
(membership.space.deletingAt === null || membership.space.deletingAt < staleClaimBefore),
|
||||||
// A linked folder is on the owner's host; other members see neither its path nor remote.
|
workspace: projectWorkspaceDto(membership.space),
|
||||||
workspace: actor.isDeploymentOwner ? projectWorkspaceDto(membership.space) : null,
|
bots: spaceBots.map((bot) => ({
|
||||||
bots: spaceBots.map(spaceBotDto),
|
id: bot.id,
|
||||||
groups: spaceGroups.map(spaceGroupDto),
|
spaceId: bot.spaceId,
|
||||||
|
kind: bot.kind,
|
||||||
|
name: bot.name,
|
||||||
|
title: bot.title,
|
||||||
|
color: bot.color,
|
||||||
|
notifyOnFinish: bot.notifyOnFinish,
|
||||||
|
pinned: bot.pinned,
|
||||||
|
sectionId: bot.sectionId,
|
||||||
|
unread: bot.unread,
|
||||||
|
preview: bot.preview,
|
||||||
|
status: bot.status,
|
||||||
|
updatedAt: bot.updatedAt,
|
||||||
|
})),
|
||||||
|
groups: spaceGroups.map((group) => ({
|
||||||
|
id: group.id,
|
||||||
|
spaceId: group.spaceId,
|
||||||
|
kind: group.kind,
|
||||||
|
name: group.name,
|
||||||
|
pinned: group.pinned,
|
||||||
|
sectionId: group.sectionId,
|
||||||
|
members: group.members,
|
||||||
|
preview: group.preview,
|
||||||
|
unread: group.unread,
|
||||||
|
updatedAt: group.updatedAt,
|
||||||
|
})),
|
||||||
externalConversations: externalConversations.filter(
|
externalConversations: externalConversations.filter(
|
||||||
(conversation) => conversation.spaceId === membership.spaceId,
|
(conversation) => conversation.spaceId === membership.spaceId,
|
||||||
),
|
),
|
||||||
@ -5372,40 +5047,6 @@ async function spaceNavigationDto(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function spaceBotDto(bot: SpaceBot): SpaceBot {
|
|
||||||
return {
|
|
||||||
id: bot.id,
|
|
||||||
spaceId: bot.spaceId,
|
|
||||||
kind: bot.kind,
|
|
||||||
name: bot.name,
|
|
||||||
title: bot.title,
|
|
||||||
color: bot.color,
|
|
||||||
notifyOnFinish: bot.notifyOnFinish,
|
|
||||||
pinned: bot.pinned,
|
|
||||||
sectionId: bot.sectionId,
|
|
||||||
unread: bot.unread,
|
|
||||||
preview: bot.preview,
|
|
||||||
status: bot.status,
|
|
||||||
updatedAt: bot.updatedAt,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function spaceGroupDto(group: SpaceGroup): SpaceGroup {
|
|
||||||
return {
|
|
||||||
id: group.id,
|
|
||||||
spaceId: group.spaceId,
|
|
||||||
kind: group.kind,
|
|
||||||
name: group.name,
|
|
||||||
pinned: group.pinned,
|
|
||||||
sectionId: group.sectionId,
|
|
||||||
members: group.members,
|
|
||||||
preview: group.preview,
|
|
||||||
...(group.previewBlock ? { previewBlock: group.previewBlock } : {}),
|
|
||||||
unread: group.unread,
|
|
||||||
updatedAt: group.updatedAt,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function partitionBySpace<T extends { spaceId: string }>(rows: T[]): Map<string, T[]> {
|
function partitionBySpace<T extends { spaceId: string }>(rows: T[]): Map<string, T[]> {
|
||||||
const partitioned = new Map<string, T[]>();
|
const partitioned = new Map<string, T[]>();
|
||||||
for (const row of rows) {
|
for (const row of rows) {
|
||||||
@ -5784,7 +5425,6 @@ function mapRoutine(row: {
|
|||||||
lastRunAt: Date | null;
|
lastRunAt: Date | null;
|
||||||
nextRunAt: Date | null;
|
nextRunAt: Date | null;
|
||||||
createdAt: Date;
|
createdAt: Date;
|
||||||
coachKey: string | null;
|
|
||||||
}) {
|
}) {
|
||||||
return {
|
return {
|
||||||
id: row.id,
|
id: row.id,
|
||||||
@ -5801,7 +5441,6 @@ function mapRoutine(row: {
|
|||||||
lastRunAt: row.lastRunAt?.toISOString() ?? null,
|
lastRunAt: row.lastRunAt?.toISOString() ?? null,
|
||||||
nextRunAt: row.nextRunAt?.toISOString() ?? null,
|
nextRunAt: row.nextRunAt?.toISOString() ?? null,
|
||||||
createdAt: row.createdAt.toISOString(),
|
createdAt: row.createdAt.toISOString(),
|
||||||
coachKey: CoachRoutineKeySchema.safeParse(row.coachKey).data ?? null,
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -1,143 +0,0 @@
|
|||||||
import type { MessageBlock } from "@rakazo/contracts";
|
|
||||||
import type { PrismaClient } from "@rakazo/db";
|
|
||||||
import { createDb, provisionMessagingIdentity } from "@rakazo/db";
|
|
||||||
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
|
||||||
import { loadMessagePage } from "./thread-message-pages.js";
|
|
||||||
|
|
||||||
const databaseUrl = process.env.DATABASE_URL;
|
|
||||||
const describePostgres =
|
|
||||||
process.env.VERIFY_DATABASE && databaseUrl ? describe.sequential : describe.skip;
|
|
||||||
|
|
||||||
describePostgres("loadMessagePage (PostgreSQL)", () => {
|
|
||||||
let prisma: PrismaClient;
|
|
||||||
let close: () => Promise<void>;
|
|
||||||
let identity: { userId: string; spaceId: string; botId: string; threadId: string };
|
|
||||||
let userRunId: string;
|
|
||||||
let peerRunId: string;
|
|
||||||
let seq = 0;
|
|
||||||
|
|
||||||
beforeAll(async () => {
|
|
||||||
const db = createDb(databaseUrl!);
|
|
||||||
prisma = db.prisma;
|
|
||||||
close = async () => {
|
|
||||||
await db.prisma.$disconnect();
|
|
||||||
await db.pool.end();
|
|
||||||
};
|
|
||||||
identity = await provisionMessagingIdentity(
|
|
||||||
prisma,
|
|
||||||
{ provider: "sendblue", address: `+1555${Date.now().toString().slice(-7)}` },
|
|
||||||
{ signupsEnabled: undefined, signupAllowlist: undefined },
|
|
||||||
);
|
|
||||||
const { spaceId, botId, threadId, userId } = identity;
|
|
||||||
const owner = { spaceId, botId, threadId, userId };
|
|
||||||
const run = async (trigger: string) => {
|
|
||||||
const task = await prisma.task.create({
|
|
||||||
data: { ...owner, prompt: "fixture", status: "completed" },
|
|
||||||
});
|
|
||||||
const created = await prisma.run.create({
|
|
||||||
data: { ...owner, taskId: task.id, status: "completed", trigger },
|
|
||||||
});
|
|
||||||
return created.id;
|
|
||||||
};
|
|
||||||
userRunId = await run("user_message");
|
|
||||||
peerRunId = await run("bot_message");
|
|
||||||
seq =
|
|
||||||
(
|
|
||||||
await prisma.message.aggregate({
|
|
||||||
where: { threadId: identity.threadId },
|
|
||||||
_max: { seq: true },
|
|
||||||
})
|
|
||||||
)._max.seq ?? 0;
|
|
||||||
});
|
|
||||||
|
|
||||||
afterAll(async () => {
|
|
||||||
if (identity) {
|
|
||||||
await prisma.messagingIdentity.deleteMany({ where: { userId: identity.userId } });
|
|
||||||
await prisma.organization.deleteMany({ where: { id: identity.spaceId } });
|
|
||||||
await prisma.user.deleteMany({ where: { id: identity.userId } });
|
|
||||||
}
|
|
||||||
await close?.();
|
|
||||||
});
|
|
||||||
|
|
||||||
async function message(id: string, runId: string | null, blocks: MessageBlock[]) {
|
|
||||||
seq += 1;
|
|
||||||
await prisma.message.create({
|
|
||||||
data: {
|
|
||||||
id: `${identity.threadId}-${id}`,
|
|
||||||
threadId: identity.threadId,
|
|
||||||
seq,
|
|
||||||
role: "bot",
|
|
||||||
runId,
|
|
||||||
blocks,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
return seq;
|
|
||||||
}
|
|
||||||
|
|
||||||
const ids = (page: Awaited<ReturnType<typeof loadMessagePage>>) =>
|
|
||||||
page.messages.map((row) => row.id.slice(identity.threadId.length + 1));
|
|
||||||
|
|
||||||
it("fills a page with visible rows across a long stretch of hidden peer activity", async () => {
|
|
||||||
await message("older", userRunId, [{ kind: "text", text: "Older answer" }]);
|
|
||||||
await message("newer", userRunId, [{ kind: "text", text: "Newer answer" }]);
|
|
||||||
for (let index = 0; index < 30; index += 1) {
|
|
||||||
await message(`peer-steps-${index}`, peerRunId, [
|
|
||||||
{ kind: "steps", steps: [{ label: "Working", count: 1 }] },
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
await message("peer-receipt", peerRunId, [
|
|
||||||
{ kind: "bot_message_received", fromBotId: "bot-2", fromBotName: "Coder", text: "Done." },
|
|
||||||
]);
|
|
||||||
await message("peer-reply", peerRunId, [{ kind: "text", text: "Peer summary" }]);
|
|
||||||
await message("orphan-run", "run-that-no-longer-exists", [{ kind: "text", text: "Kept" }]);
|
|
||||||
|
|
||||||
const first = await loadMessagePage(prisma, identity.threadId, undefined, 3);
|
|
||||||
expect(ids(first)).toEqual(["peer-receipt", "peer-reply", "orphan-run"]);
|
|
||||||
|
|
||||||
const second = await loadMessagePage(prisma, identity.threadId, first.olderCursor!, 2);
|
|
||||||
expect(ids(second)).toEqual(["older", "newer"]);
|
|
||||||
|
|
||||||
const peerView = await loadMessagePage(
|
|
||||||
prisma,
|
|
||||||
identity.threadId,
|
|
||||||
undefined,
|
|
||||||
3,
|
|
||||||
undefined,
|
|
||||||
true,
|
|
||||||
);
|
|
||||||
expect(ids(peerView)).toEqual(["peer-receipt", "peer-reply", "orphan-run"]);
|
|
||||||
const peerOlder = await loadMessagePage(
|
|
||||||
prisma,
|
|
||||||
identity.threadId,
|
|
||||||
peerView.olderCursor!,
|
|
||||||
1,
|
|
||||||
undefined,
|
|
||||||
true,
|
|
||||||
);
|
|
||||||
expect(ids(peerOlder)).toEqual(["peer-steps-29"]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("skips a receipt-only page for web but returns it when receipts are displayed", async () => {
|
|
||||||
await message("before-receipts", userRunId, [{ kind: "text", text: "Answer" }]);
|
|
||||||
await message("sent", userRunId, [
|
|
||||||
{ kind: "bot_message_sent", toBotId: "bot-2", toBotName: "Coder", text: "Check this." },
|
|
||||||
]);
|
|
||||||
await message("received", peerRunId, [
|
|
||||||
{ kind: "bot_message_received", fromBotId: "bot-2", fromBotName: "Coder", text: "Done." },
|
|
||||||
]);
|
|
||||||
|
|
||||||
const web = await loadMessagePage(prisma, identity.threadId, undefined, 2);
|
|
||||||
expect(ids(web)).toEqual(["orphan-run", "before-receipts"]);
|
|
||||||
|
|
||||||
const mobile = await loadMessagePage(
|
|
||||||
prisma,
|
|
||||||
identity.threadId,
|
|
||||||
undefined,
|
|
||||||
2,
|
|
||||||
undefined,
|
|
||||||
false,
|
|
||||||
true,
|
|
||||||
);
|
|
||||||
expect(ids(mobile)).toEqual(["sent", "received"]);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@ -43,6 +43,98 @@ describe("thread message pages", () => {
|
|||||||
).toBe(false);
|
).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
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",
|
||||||
|
"message-reply",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("filters peer-run activity 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: "steps", steps: [{ label: "Echoed peer reply", count: 1 }] }],
|
||||||
|
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("keeps peer text around-page targets and omits peer activity", async () => {
|
it("keeps peer text around-page targets and omits peer activity", async () => {
|
||||||
const findMany = vi.fn(async () => [
|
const findMany = vi.fn(async () => [
|
||||||
{
|
{
|
||||||
@ -189,6 +281,45 @@ describe("thread message pages", () => {
|
|||||||
expect(page.messages.map((message) => message.id)).toEqual(["message-peer"]);
|
expect(page.messages.map((message) => message.id)).toEqual(["message-peer"]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("scans past a page containing only hidden peer-run activity", async () => {
|
||||||
|
const row = (seq: number, runId: string, kind: "text" | "steps" = "text") => ({
|
||||||
|
id: `message-${seq}`,
|
||||||
|
threadId: "thread-1",
|
||||||
|
seq,
|
||||||
|
role: "bot",
|
||||||
|
blocks:
|
||||||
|
kind === "steps"
|
||||||
|
? [{ kind: "steps", steps: [{ label: String(seq), count: 1 }] }]
|
||||||
|
: [{ 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", "steps"),
|
||||||
|
row(3, "run-peer", "steps"),
|
||||||
|
row(2, "run-peer", "steps"),
|
||||||
|
])
|
||||||
|
.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 () => {
|
it("scans past a receipt-only page so web can reach older user-visible rows", async () => {
|
||||||
const receiptRows = [
|
const receiptRows = [
|
||||||
{
|
{
|
||||||
@ -252,13 +383,21 @@ describe("thread message pages", () => {
|
|||||||
createdAt: new Date("2026-08-16T00:00:01.000Z"),
|
createdAt: new Date("2026-08-16T00:00:01.000Z"),
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
const queryRaw = vi.fn().mockResolvedValueOnce(receiptRows).mockResolvedValueOnce(olderRows);
|
const findMany = vi.fn().mockResolvedValueOnce(receiptRows).mockResolvedValueOnce(olderRows);
|
||||||
const prisma = { $queryRaw: queryRaw } as unknown as PrismaClient;
|
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);
|
const page = await loadMessagePage(prisma, "thread-1", undefined, 2);
|
||||||
|
|
||||||
expect(page.messages.map((message) => message.id)).toEqual(["message-user"]);
|
expect(page.messages.map((message) => message.id)).toEqual(["message-user"]);
|
||||||
expect(queryRaw).toHaveBeenCalledTimes(2);
|
expect(findMany).toHaveBeenCalledTimes(2);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("returns a receipt-only page when the client displays peer receipts", async () => {
|
it("returns a receipt-only page when the client displays peer receipts", async () => {
|
||||||
@ -280,8 +419,11 @@ describe("thread message pages", () => {
|
|||||||
runId: "run-peer",
|
runId: "run-peer",
|
||||||
createdAt: new Date(`2026-08-16T00:00:0${seq}.000Z`),
|
createdAt: new Date(`2026-08-16T00:00:0${seq}.000Z`),
|
||||||
});
|
});
|
||||||
const queryRaw = vi.fn(async () => [receipt(3), receipt(2), receipt(1)]);
|
const findMany = vi.fn(async () => [receipt(3), receipt(2), receipt(1)]);
|
||||||
const prisma = { $queryRaw: queryRaw } as unknown as PrismaClient;
|
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);
|
const page = await loadMessagePage(prisma, "thread-1", undefined, 2, undefined, false, true);
|
||||||
|
|
||||||
@ -290,10 +432,10 @@ describe("thread message pages", () => {
|
|||||||
"message-receipt-3",
|
"message-receipt-3",
|
||||||
]);
|
]);
|
||||||
expect(page.olderCursor).toBe(2);
|
expect(page.olderCursor).toBe(2);
|
||||||
expect(queryRaw).toHaveBeenCalledTimes(1);
|
expect(findMany).toHaveBeenCalledTimes(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("queries the bot messages view before the cursor as an ascending bounded page", async () => {
|
it("queries before the cursor and returns an ascending bounded page", async () => {
|
||||||
const findMany = vi.fn(async () =>
|
const findMany = vi.fn(async () =>
|
||||||
[5, 4, 3].map((seq) => ({
|
[5, 4, 3].map((seq) => ({
|
||||||
id: `message-${seq}`,
|
id: `message-${seq}`,
|
||||||
@ -307,7 +449,7 @@ describe("thread message pages", () => {
|
|||||||
);
|
);
|
||||||
const prisma = { message: { findMany } } as unknown as PrismaClient;
|
const prisma = { message: { findMany } } as unknown as PrismaClient;
|
||||||
|
|
||||||
const page = await loadMessagePage(prisma, "thread-1", 6, 2, undefined, true);
|
const page = await loadMessagePage(prisma, "thread-1", 6, 2);
|
||||||
|
|
||||||
expect(findMany).toHaveBeenCalledWith({
|
expect(findMany).toHaveBeenCalledWith({
|
||||||
where: { threadId: "thread-1", seq: { lt: 6 } },
|
where: { threadId: "thread-1", seq: { lt: 6 } },
|
||||||
@ -319,7 +461,7 @@ describe("thread message pages", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("ends pagination when the database returns no lookahead row", async () => {
|
it("ends pagination when the database returns no lookahead row", async () => {
|
||||||
const queryRaw = vi.fn(async () => [
|
const findMany = vi.fn(async () => [
|
||||||
{
|
{
|
||||||
id: "message-0",
|
id: "message-0",
|
||||||
threadId: "thread-1",
|
threadId: "thread-1",
|
||||||
@ -330,7 +472,7 @@ describe("thread message pages", () => {
|
|||||||
createdAt: new Date("2026-08-16T00:00:00.000Z"),
|
createdAt: new Date("2026-08-16T00:00:00.000Z"),
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
const prisma = { $queryRaw: queryRaw } as unknown as PrismaClient;
|
const prisma = { message: { findMany } } as unknown as PrismaClient;
|
||||||
|
|
||||||
const page = await loadMessagePage(prisma, "thread-1", 1, 2);
|
const page = await loadMessagePage(prisma, "thread-1", 1, 2);
|
||||||
|
|
||||||
|
|||||||
@ -1,18 +1,9 @@
|
|||||||
import type { MessageBlock, ThreadMessage, ThreadMessagePage } from "@rakazo/contracts";
|
import type { MessageBlock, ThreadMessage, ThreadMessagePage } from "@rakazo/contracts";
|
||||||
import { isPeerReceiptBlocks } from "@rakazo/core";
|
import { isPeerReceiptBlocks } from "@rakazo/core";
|
||||||
import type { PrismaClient } from "@rakazo/db";
|
import type { Prisma, PrismaClient } from "@rakazo/db";
|
||||||
import { Prisma } from "@rakazo/db";
|
|
||||||
|
|
||||||
type MessageDb = PrismaClient | Prisma.TransactionClient;
|
type MessageDb = PrismaClient | Prisma.TransactionClient;
|
||||||
|
|
||||||
/** Peer-run rows that stay in the normal transcript: receipts, ask cards and the bot's own reply. */
|
|
||||||
const PEER_VISIBLE_BLOCK_KINDS: readonly string[] = [
|
|
||||||
"bot_message_sent",
|
|
||||||
"bot_message_received",
|
|
||||||
"ask",
|
|
||||||
"text",
|
|
||||||
];
|
|
||||||
|
|
||||||
export async function loadMessagePage(
|
export async function loadMessagePage(
|
||||||
prisma: MessageDb,
|
prisma: MessageDb,
|
||||||
threadId: string,
|
threadId: string,
|
||||||
@ -56,69 +47,37 @@ export async function loadMessagePage(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (includePeerRuns) {
|
let cursor = before;
|
||||||
|
while (true) {
|
||||||
const rows = await prisma.message.findMany({
|
const rows = await prisma.message.findMany({
|
||||||
where: {
|
where: {
|
||||||
threadId,
|
threadId,
|
||||||
...(before === undefined ? {} : { seq: { lt: before } }),
|
...(cursor === undefined ? {} : { seq: { lt: cursor } }),
|
||||||
},
|
},
|
||||||
orderBy: { seq: "desc" },
|
orderBy: { seq: "desc" },
|
||||||
take: pageSize + 1,
|
take: pageSize + 1,
|
||||||
});
|
});
|
||||||
return toPage(threadId, rows, pageSize);
|
const hasOlder = rows.length > pageSize;
|
||||||
}
|
const pageRows = rows.slice(0, pageSize).reverse();
|
||||||
|
const visibleRows = includePeerRuns ? pageRows : await withoutPeerRunMessages(prisma, pageRows);
|
||||||
let cursor = before;
|
|
||||||
while (true) {
|
|
||||||
const rows = await visibleMessageRows(prisma, threadId, cursor, pageSize + 1);
|
|
||||||
const page = toPage(threadId, rows, pageSize);
|
|
||||||
// Web hides receipts client-side, so its receipt-only pages keep scanning.
|
// Web hides receipts client-side, so its receipt-only pages keep scanning.
|
||||||
// Mobile explicitly retains them and must receive each page for pagination.
|
// Mobile explicitly retains them and must receive each page for pagination.
|
||||||
const hasSubstantive = page.messages.some((message) => !isPeerReceiptBlocks(message.blocks));
|
const hasSubstantive = visibleRows.some(
|
||||||
if (hasSubstantive || includePeerReceipts || page.olderCursor === null) return page;
|
(row) => !isPeerReceiptBlocks(row.blocks as MessageBlock[]),
|
||||||
cursor = page.olderCursor;
|
);
|
||||||
|
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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
type MessageRow = Parameters<typeof toThreadMessage>[0];
|
|
||||||
|
|
||||||
function toPage(threadId: string, rows: MessageRow[], pageSize: number): ThreadMessagePage {
|
|
||||||
const pageRows = rows.slice(0, pageSize).reverse();
|
|
||||||
return {
|
|
||||||
threadId,
|
|
||||||
messages: pageRows.map(toThreadMessage),
|
|
||||||
olderCursor: rows.length > pageSize ? (pageRows[0]?.seq ?? null) : null,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Newest-first transcript rows with peer-run activity excluded in the database, so a long
|
|
||||||
* peer-only stretch costs one query instead of a rescan per hidden page.
|
|
||||||
*/
|
|
||||||
function visibleMessageRows(
|
|
||||||
prisma: MessageDb,
|
|
||||||
threadId: string,
|
|
||||||
before: number | undefined,
|
|
||||||
limit: number,
|
|
||||||
): Promise<MessageRow[]> {
|
|
||||||
return prisma.$queryRaw<MessageRow[]>`
|
|
||||||
SELECT m.id, m."threadId", m.seq, m.role, m.blocks, m."botId", m."replyToMessageId",
|
|
||||||
m."replyQuote", m."runId", m."createdAt"
|
|
||||||
FROM messages m
|
|
||||||
LEFT JOIN runs r ON r.id = m."runId"
|
|
||||||
WHERE m."threadId" = ${threadId}
|
|
||||||
${before === undefined ? Prisma.empty : Prisma.sql`AND m.seq < ${before}`}
|
|
||||||
AND (r.trigger IS DISTINCT FROM 'bot_message' OR ${Prisma.join(
|
|
||||||
PEER_VISIBLE_BLOCK_KINDS.map(
|
|
||||||
(kind) => Prisma.sql`m.blocks @> ${JSON.stringify([{ kind }])}::jsonb`,
|
|
||||||
),
|
|
||||||
" OR ",
|
|
||||||
)})
|
|
||||||
ORDER BY m.seq DESC
|
|
||||||
LIMIT ${limit}
|
|
||||||
`;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function loadAllMessages(
|
export async function loadAllMessages(
|
||||||
prisma: PrismaClient,
|
prisma: PrismaClient,
|
||||||
threadId: string,
|
threadId: string,
|
||||||
@ -147,8 +106,15 @@ async function withoutPeerRunMessages<T extends { runId: string | null; blocks:
|
|||||||
const peerRunIds = new Set(peerRuns.map((run) => run.id));
|
const peerRunIds = new Set(peerRuns.map((run) => run.id));
|
||||||
return rows.filter((row) => {
|
return rows.filter((row) => {
|
||||||
if (!row.runId || !peerRunIds.has(row.runId)) return true;
|
if (!row.runId || !peerRunIds.has(row.runId)) return true;
|
||||||
|
// Keep peer receipts (chips), ask cards, and the bot's own text reply.
|
||||||
const blocks = row.blocks as MessageBlock[];
|
const blocks = row.blocks as MessageBlock[];
|
||||||
return blocks.some((block) => PEER_VISIBLE_BLOCK_KINDS.includes(block.kind));
|
return blocks.some(
|
||||||
|
(block) =>
|
||||||
|
block.kind === "bot_message_sent" ||
|
||||||
|
block.kind === "bot_message_received" ||
|
||||||
|
block.kind === "ask" ||
|
||||||
|
block.kind === "text",
|
||||||
|
);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -193,8 +159,10 @@ export function shouldForwardPeerThreadEvent(event: {
|
|||||||
!!block &&
|
!!block &&
|
||||||
typeof block === "object" &&
|
typeof block === "object" &&
|
||||||
"kind" in block &&
|
"kind" in block &&
|
||||||
typeof block.kind === "string" &&
|
(block.kind === "bot_message_received" ||
|
||||||
PEER_VISIBLE_BLOCK_KINDS.includes(block.kind),
|
block.kind === "bot_message_sent" ||
|
||||||
|
block.kind === "ask" ||
|
||||||
|
block.kind === "text"),
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -183,7 +183,7 @@ describe("threadSnapshot", () => {
|
|||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
const tx = {
|
const tx = {
|
||||||
$queryRaw: threadQueries(),
|
$queryRaw: vi.fn().mockResolvedValue([{ id: "thread-1" }]),
|
||||||
message: { findMany: vi.fn().mockResolvedValue([]) },
|
message: { findMany: vi.fn().mockResolvedValue([]) },
|
||||||
event: {
|
event: {
|
||||||
findFirst: vi.fn().mockResolvedValue({ seq: 4 }),
|
findFirst: vi.fn().mockResolvedValue({ seq: 4 }),
|
||||||
@ -203,10 +203,7 @@ describe("threadSnapshot", () => {
|
|||||||
|
|
||||||
const snapshot = await threadSnapshot({ prisma }, target);
|
const snapshot = await threadSnapshot({ prisma }, target);
|
||||||
|
|
||||||
expect(tx.$queryRaw).toHaveBeenCalledWith(
|
expect(tx.$queryRaw).toHaveBeenCalledOnce();
|
||||||
expect.arrayContaining([expect.stringContaining("FOR SHARE")]),
|
|
||||||
"thread-1",
|
|
||||||
);
|
|
||||||
expect(findManyEvents).toHaveBeenCalledWith(
|
expect(findManyEvents).toHaveBeenCalledWith(
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
where: expect.objectContaining({
|
where: expect.objectContaining({
|
||||||
@ -248,7 +245,7 @@ describe("threadSnapshot", () => {
|
|||||||
const findManyEvents = vi.fn();
|
const findManyEvents = vi.fn();
|
||||||
const findFirstRun = botRunFindFirst([run]);
|
const findFirstRun = botRunFindFirst([run]);
|
||||||
const tx = {
|
const tx = {
|
||||||
$queryRaw: threadQueries(),
|
$queryRaw: vi.fn().mockResolvedValue([{ id: "thread-1" }]),
|
||||||
message: { findMany: vi.fn().mockResolvedValue([]) },
|
message: { findMany: vi.fn().mockResolvedValue([]) },
|
||||||
event: {
|
event: {
|
||||||
findFirst: vi.fn().mockResolvedValue(null),
|
findFirst: vi.fn().mockResolvedValue(null),
|
||||||
@ -333,7 +330,7 @@ describe("threadSnapshot", () => {
|
|||||||
prisma: {
|
prisma: {
|
||||||
$transaction: vi.fn(async (callback: (client: unknown) => unknown) =>
|
$transaction: vi.fn(async (callback: (client: unknown) => unknown) =>
|
||||||
callback({
|
callback({
|
||||||
$queryRaw: threadQueries(),
|
$queryRaw: vi.fn().mockResolvedValue([{ id: "thread-1" }]),
|
||||||
message: { findMany: vi.fn().mockResolvedValue([]) },
|
message: { findMany: vi.fn().mockResolvedValue([]) },
|
||||||
event: {
|
event: {
|
||||||
findFirst: vi.fn().mockResolvedValue(null),
|
findFirst: vi.fn().mockResolvedValue(null),
|
||||||
@ -388,7 +385,7 @@ describe("threadSnapshot", () => {
|
|||||||
};
|
};
|
||||||
const findFirstRun = botRunFindFirst([failed, completed]);
|
const findFirstRun = botRunFindFirst([failed, completed]);
|
||||||
const tx = {
|
const tx = {
|
||||||
$queryRaw: threadQueries(),
|
$queryRaw: vi.fn().mockResolvedValue([{ id: "thread-1" }]),
|
||||||
message: { findMany: vi.fn().mockResolvedValue([]) },
|
message: { findMany: vi.fn().mockResolvedValue([]) },
|
||||||
event: {
|
event: {
|
||||||
findFirst: vi.fn().mockResolvedValue(null),
|
findFirst: vi.fn().mockResolvedValue(null),
|
||||||
@ -424,7 +421,7 @@ describe("threadSnapshot", () => {
|
|||||||
const findManyEvents = vi.fn();
|
const findManyEvents = vi.fn();
|
||||||
const findFirstRun = botRunFindFirst([]);
|
const findFirstRun = botRunFindFirst([]);
|
||||||
const tx = {
|
const tx = {
|
||||||
$queryRaw: threadQueries(),
|
$queryRaw: vi.fn().mockResolvedValue([{ id: "thread-1" }]),
|
||||||
message: { findMany: vi.fn().mockResolvedValue([]) },
|
message: { findMany: vi.fn().mockResolvedValue([]) },
|
||||||
event: {
|
event: {
|
||||||
findFirst: vi.fn().mockResolvedValue(null),
|
findFirst: vi.fn().mockResolvedValue(null),
|
||||||
@ -928,16 +925,9 @@ function groupRunFindMany(input: { active?: unknown[]; terminals?: unknown[] })
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The snapshot's thread lock finds the thread; its message page query finds no rows. */
|
|
||||||
function threadQueries() {
|
|
||||||
return vi.fn(async (sql: TemplateStringsArray) =>
|
|
||||||
sql.join("").includes("FOR SHARE") ? [{ id: "thread-1" }] : [],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function groupPrisma(findManyRuns: ReturnType<typeof groupRunFindMany>) {
|
function groupPrisma(findManyRuns: ReturnType<typeof groupRunFindMany>) {
|
||||||
const tx = {
|
const tx = {
|
||||||
$queryRaw: threadQueries(),
|
$queryRaw: vi.fn().mockResolvedValue([{ id: "thread-1" }]),
|
||||||
message: { findMany: vi.fn().mockResolvedValue([]) },
|
message: { findMany: vi.fn().mockResolvedValue([]) },
|
||||||
event: {
|
event: {
|
||||||
findFirst: vi.fn().mockResolvedValue(null),
|
findFirst: vi.fn().mockResolvedValue(null),
|
||||||
|
|||||||
96
apps/api/src/trading-bot-proxy.test.ts
Normal file
96
apps/api/src/trading-bot-proxy.test.ts
Normal file
@ -0,0 +1,96 @@
|
|||||||
|
import { TRADING_BOT_PROXY_PATH } from "@rakazo/core";
|
||||||
|
import { Hono } from "hono";
|
||||||
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
import { mountTradingBotStatus } from "./trading-bot-proxy.js";
|
||||||
|
|
||||||
|
function proxyApp(options: {
|
||||||
|
baseUrl?: string;
|
||||||
|
authorize?: (request: Request) => Promise<boolean>;
|
||||||
|
fetchImpl?: typeof fetch;
|
||||||
|
}) {
|
||||||
|
const app = new Hono();
|
||||||
|
mountTradingBotStatus(app, {
|
||||||
|
baseUrl: options.baseUrl,
|
||||||
|
authorize: options.authorize ?? (async () => true),
|
||||||
|
fetchImpl: options.fetchImpl,
|
||||||
|
});
|
||||||
|
return app;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("trading bot status proxy", () => {
|
||||||
|
it("stays idle when TRADING_BOT_URL is unset and does not call upstream", async () => {
|
||||||
|
const fetchImpl = vi.fn();
|
||||||
|
const response = await proxyApp({ fetchImpl }).request(TRADING_BOT_PROXY_PATH);
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
expect(await response.json()).toEqual({ configured: false });
|
||||||
|
expect(fetchImpl).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("requires a session before it proxies a configured origin", async () => {
|
||||||
|
const fetchImpl = vi.fn();
|
||||||
|
const response = await proxyApp({
|
||||||
|
baseUrl: "http://bot.test:8000",
|
||||||
|
authorize: async () => false,
|
||||||
|
fetchImpl,
|
||||||
|
}).request(TRADING_BOT_PROXY_PATH, { headers: { cookie: "session=secret" } });
|
||||||
|
expect(response.status).toBe(401);
|
||||||
|
expect(fetchImpl).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns the mapped status and does not forward browser credentials", async () => {
|
||||||
|
const fetchImpl = vi.fn(
|
||||||
|
async () =>
|
||||||
|
new Response(
|
||||||
|
JSON.stringify({ mode: "LIVE", adapter_count: 2, last_error: "adapter timeout" }),
|
||||||
|
{ status: 200, headers: { "content-type": "application/json" } },
|
||||||
|
),
|
||||||
|
);
|
||||||
|
const response = await proxyApp({
|
||||||
|
baseUrl: "http://bot.test:8000",
|
||||||
|
fetchImpl,
|
||||||
|
}).request(TRADING_BOT_PROXY_PATH, {
|
||||||
|
headers: { cookie: "session=secret", authorization: "Bearer browser-token" },
|
||||||
|
});
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
expect(await response.json()).toEqual({
|
||||||
|
configured: true,
|
||||||
|
reachable: true,
|
||||||
|
status: {
|
||||||
|
mode: "live",
|
||||||
|
kind: "live",
|
||||||
|
adapterCount: 2,
|
||||||
|
lastError: "adapter timeout",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(fetchImpl).toHaveBeenCalledTimes(1);
|
||||||
|
const [url, init] = fetchImpl.mock.calls[0] as [string, RequestInit];
|
||||||
|
expect(url).toBe("http://bot.test:8000/bot/api/status");
|
||||||
|
const headers = new Headers(init.headers);
|
||||||
|
expect(headers.get("cookie")).toBeNull();
|
||||||
|
expect(headers.get("authorization")).toBeNull();
|
||||||
|
expect(init.redirect).toBe("error");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("hides an unmapped body and an upstream failure instead of claiming live", async () => {
|
||||||
|
const unmapped = vi.fn(
|
||||||
|
async () =>
|
||||||
|
new Response(JSON.stringify({ status: "ok" }), {
|
||||||
|
status: 200,
|
||||||
|
headers: { "content-type": "application/json" },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const unknown = await proxyApp({
|
||||||
|
baseUrl: "http://bot.test:8000",
|
||||||
|
fetchImpl: unmapped,
|
||||||
|
}).request(TRADING_BOT_PROXY_PATH);
|
||||||
|
expect(await unknown.json()).toEqual({ configured: true, reachable: true, status: null });
|
||||||
|
|
||||||
|
const down = vi.fn(async () => new Response("nope", { status: 500 }));
|
||||||
|
const offline = await proxyApp({
|
||||||
|
baseUrl: "http://bot.test:8000",
|
||||||
|
fetchImpl: down,
|
||||||
|
}).request(TRADING_BOT_PROXY_PATH);
|
||||||
|
expect(offline.status).toBe(502);
|
||||||
|
expect(await offline.json()).toEqual({ configured: true, reachable: false });
|
||||||
|
});
|
||||||
|
});
|
||||||
79
apps/api/src/trading-bot-proxy.ts
Normal file
79
apps/api/src/trading-bot-proxy.ts
Normal file
@ -0,0 +1,79 @@
|
|||||||
|
import {
|
||||||
|
mapTradingBotStatus,
|
||||||
|
readBoundedJsonResponse,
|
||||||
|
TRADING_BOT_PROXY_PATH,
|
||||||
|
TRADING_BOT_STATUS_MAX_BYTES,
|
||||||
|
TRADING_BOT_STATUS_TIMEOUT_MS,
|
||||||
|
tradingBotStatusUrl,
|
||||||
|
} from "@rakazo/core";
|
||||||
|
import type { Hono } from "hono";
|
||||||
|
|
||||||
|
export function mountTradingBotStatus(
|
||||||
|
app: Hono,
|
||||||
|
options: {
|
||||||
|
/** Normalized http(s) origin. Unset leaves the route idle for SaaS builds. */
|
||||||
|
baseUrl: string | undefined;
|
||||||
|
authorize: (request: Request) => Promise<boolean>;
|
||||||
|
fetchImpl?: typeof fetch;
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
const fetchImpl = options.fetchImpl ?? fetch;
|
||||||
|
app.get(TRADING_BOT_PROXY_PATH, async (c) => {
|
||||||
|
c.header("cache-control", "no-store");
|
||||||
|
if (!options.baseUrl) return c.json({ configured: false });
|
||||||
|
const allowed = await options.authorize(c.req.raw).catch(() => false);
|
||||||
|
if (!allowed) return c.json({ error: "Unauthorized" }, 401);
|
||||||
|
|
||||||
|
const timeout = linkAbort(c.req.raw.signal, TRADING_BOT_STATUS_TIMEOUT_MS);
|
||||||
|
try {
|
||||||
|
const response = await fetchImpl(tradingBotStatusUrl(options.baseUrl), {
|
||||||
|
method: "GET",
|
||||||
|
redirect: "error",
|
||||||
|
cache: "no-store",
|
||||||
|
signal: timeout.signal,
|
||||||
|
headers: { accept: "application/json" },
|
||||||
|
});
|
||||||
|
if (!response.ok) {
|
||||||
|
cancelBody(response);
|
||||||
|
return c.json({ configured: true, reachable: false }, 502);
|
||||||
|
}
|
||||||
|
const body = await readBoundedJsonResponse<unknown>(
|
||||||
|
response,
|
||||||
|
TRADING_BOT_STATUS_MAX_BYTES,
|
||||||
|
timeout.signal,
|
||||||
|
);
|
||||||
|
const status = mapTradingBotStatus(body);
|
||||||
|
return c.json({ configured: true, reachable: true, status: status ?? null });
|
||||||
|
} catch {
|
||||||
|
return c.json({ configured: true, reachable: false }, 502);
|
||||||
|
} finally {
|
||||||
|
timeout.cancel();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function linkAbort(
|
||||||
|
parent: AbortSignal,
|
||||||
|
timeoutMs: number,
|
||||||
|
): { signal: AbortSignal; cancel: () => void } {
|
||||||
|
const controller = new AbortController();
|
||||||
|
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
||||||
|
const onParent = () => controller.abort();
|
||||||
|
if (parent.aborted) controller.abort();
|
||||||
|
else parent.addEventListener("abort", onParent);
|
||||||
|
return {
|
||||||
|
signal: controller.signal,
|
||||||
|
cancel: () => {
|
||||||
|
clearTimeout(timer);
|
||||||
|
parent.removeEventListener("abort", onParent);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function cancelBody(response: Response): void {
|
||||||
|
try {
|
||||||
|
void Promise.resolve(response.body?.cancel()).catch(() => undefined);
|
||||||
|
} catch {
|
||||||
|
// Best-effort. The caller still reports the bot as unreachable.
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,6 +1,4 @@
|
|||||||
import { ORPCError } from "@orpc/server";
|
import { ORPCError } from "@orpc/server";
|
||||||
import type { CoachRhythmDeps } from "@rakazo/adapters";
|
|
||||||
import { syncCoachTimeZone } from "@rakazo/adapters";
|
|
||||||
import type {
|
import type {
|
||||||
Actor,
|
Actor,
|
||||||
ForgetUserFacts,
|
ForgetUserFacts,
|
||||||
@ -31,32 +29,32 @@ export function getProfile(prisma: PrismaClient, actor: Actor): Promise<UserProf
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function updateProfile(
|
export async function updateProfile(
|
||||||
deps: CoachRhythmDeps,
|
prisma: PrismaClient,
|
||||||
actor: Actor,
|
actor: Actor,
|
||||||
input: UpdateUserProfile,
|
input: UpdateUserProfile,
|
||||||
): Promise<UserProfileState> {
|
): Promise<UserProfileState> {
|
||||||
try {
|
try {
|
||||||
await updateUserProfile(deps.prisma, actor.userId, input);
|
await updateUserProfile(prisma, actor.userId, input);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
throw toRpcError(error);
|
throw toRpcError(error);
|
||||||
}
|
}
|
||||||
if (input.timeZone !== undefined) await syncCoachTimeZone(deps, actor.userId);
|
return getUserProfileState(prisma, actor.userId);
|
||||||
return getUserProfileState(deps.prisma, actor.userId);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Unknown ids are ignored, so a caller cannot probe another user's facts. */
|
/** Unknown ids are ignored, so a caller cannot probe another user's facts. */
|
||||||
export async function forgetProfile(
|
export async function forgetProfile(
|
||||||
deps: CoachRhythmDeps,
|
prisma: PrismaClient,
|
||||||
actor: Actor,
|
actor: Actor,
|
||||||
input: ForgetUserFacts,
|
input: ForgetUserFacts,
|
||||||
): Promise<UserProfileState> {
|
): Promise<UserProfileState> {
|
||||||
const target = input.factIds
|
await forgetUserFacts(
|
||||||
? { factIds: input.factIds }
|
prisma,
|
||||||
: input.category
|
actor.userId,
|
||||||
? { category: input.category }
|
input.factIds
|
||||||
: { all: true as const };
|
? { factIds: input.factIds }
|
||||||
await forgetUserFacts(deps.prisma, actor.userId, target);
|
: input.category
|
||||||
// Forgetting everything clears the time zone too.
|
? { category: input.category }
|
||||||
if ("all" in target) await syncCoachTimeZone(deps, actor.userId);
|
: { all: true },
|
||||||
return getUserProfileState(deps.prisma, actor.userId);
|
);
|
||||||
|
return getUserProfileState(prisma, actor.userId);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,91 +0,0 @@
|
|||||||
import type { Server } from "node:http";
|
|
||||||
import { createServer } from "node:http";
|
|
||||||
import type { AddressInfo } from "node:net";
|
|
||||||
import { NoVoiceConfigured } from "@rakazo/adapters";
|
|
||||||
import type { Actor } from "@rakazo/contracts";
|
|
||||||
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
|
||||||
import type { VoiceDeps } from "./voice.js";
|
|
||||||
import { toVoiceStatus, transcribeVoice } from "./voice.js";
|
|
||||||
|
|
||||||
const actor: Actor = {
|
|
||||||
userId: "user-1",
|
|
||||||
spaceId: "space-1",
|
|
||||||
email: "user@rakazo.test",
|
|
||||||
isDeploymentOwner: false,
|
|
||||||
};
|
|
||||||
|
|
||||||
/** A user without a voice credential, on a deployment whose media settings are `settings`. */
|
|
||||||
function deps(settings: object | null, ciphertext: string): VoiceDeps {
|
|
||||||
return {
|
|
||||||
prisma: {
|
|
||||||
spaceVoicePreference: { findFirst: async () => null },
|
|
||||||
integrationProviderConfig: {
|
|
||||||
findUnique: async () => (settings ? { id: "media", ciphertext } : null),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
secrets: { load: () => JSON.stringify(settings) },
|
|
||||||
} as unknown as VoiceDeps;
|
|
||||||
}
|
|
||||||
|
|
||||||
describe("dictation through the self-hosted transcription server", () => {
|
|
||||||
let server: Server;
|
|
||||||
let baseUrl = "";
|
|
||||||
const uploads: string[] = [];
|
|
||||||
|
|
||||||
beforeAll(async () => {
|
|
||||||
server = createServer((request, response) => {
|
|
||||||
const chunks: Buffer[] = [];
|
|
||||||
request.on("data", (chunk: Buffer) => chunks.push(chunk));
|
|
||||||
request.on("end", () => {
|
|
||||||
uploads.push(`${request.url} ${Buffer.concat(chunks).toString("latin1")}`);
|
|
||||||
response.setHeader("content-type", "application/json");
|
|
||||||
response.end(JSON.stringify({ text: " Remind me to call Ada. " }));
|
|
||||||
});
|
|
||||||
});
|
|
||||||
await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve));
|
|
||||||
baseUrl = `http://127.0.0.1:${(server.address() as AddressInfo).port}/v1`;
|
|
||||||
});
|
|
||||||
|
|
||||||
afterAll(async () => {
|
|
||||||
await new Promise((resolve) => server.close(resolve));
|
|
||||||
});
|
|
||||||
|
|
||||||
it("transcribes when no voice provider can", async () => {
|
|
||||||
const result = await transcribeVoice(
|
|
||||||
deps({ comfyui: null, transcription: { baseUrl } }, "media-a"),
|
|
||||||
actor,
|
|
||||||
{ audio: new Uint8Array([1, 2, 3]), mimeType: "audio/webm;codecs=opus" },
|
|
||||||
);
|
|
||||||
expect(result).toEqual({ text: "Remind me to call Ada." });
|
|
||||||
expect(uploads[0]).toContain("/v1/audio/transcriptions");
|
|
||||||
expect(uploads[0]).toContain('filename="audio.webm"');
|
|
||||||
});
|
|
||||||
|
|
||||||
it("keeps asking for a voice key when no server is set up either", async () => {
|
|
||||||
await expect(
|
|
||||||
transcribeVoice(deps(null, "none"), actor, {
|
|
||||||
audio: new Uint8Array([1]),
|
|
||||||
mimeType: "audio/webm",
|
|
||||||
}),
|
|
||||||
).rejects.toBeInstanceOf(NoVoiceConfigured);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("refuses an empty recording before uploading it", async () => {
|
|
||||||
const before = uploads.length;
|
|
||||||
await expect(
|
|
||||||
transcribeVoice(deps({ comfyui: null, transcription: { baseUrl } }, "media-b"), actor, {
|
|
||||||
audio: new Uint8Array(),
|
|
||||||
mimeType: "audio/webm",
|
|
||||||
}),
|
|
||||||
).rejects.toThrow("That recording is empty or too large.");
|
|
||||||
expect(uploads).toHaveLength(before);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("reports server dictation in the voice status", () => {
|
|
||||||
expect(toVoiceStatus(null).transcribe).toBe(false);
|
|
||||||
expect(toVoiceStatus(null, { mediaTranscription: true })).toMatchObject({
|
|
||||||
configured: false,
|
|
||||||
transcribe: true,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@ -1,12 +1,10 @@
|
|||||||
import { ORPCError } from "@orpc/server";
|
import { ORPCError } from "@orpc/server";
|
||||||
import type { AdapterContext } from "@rakazo/adapter-kit";
|
import type { AdapterContext } from "@rakazo/adapter-kit";
|
||||||
import type { EncryptedSecretStore } from "@rakazo/adapters";
|
|
||||||
import {
|
import {
|
||||||
createOpenAiCompatibleTranscriptionProvider,
|
|
||||||
createVoiceProvider,
|
createVoiceProvider,
|
||||||
|
type EncryptedSecretStore,
|
||||||
isVoiceProviderId,
|
isVoiceProviderId,
|
||||||
listVoiceCatalog,
|
listVoiceCatalog,
|
||||||
loadMediaSettings,
|
|
||||||
MAX_SPEAK_CHARS,
|
MAX_SPEAK_CHARS,
|
||||||
MAX_TRANSCRIBE_BYTES,
|
MAX_TRANSCRIBE_BYTES,
|
||||||
NoVoiceConfigured,
|
NoVoiceConfigured,
|
||||||
@ -53,20 +51,12 @@ export function catalogEntry(provider: string) {
|
|||||||
return voiceCatalogEntry(provider);
|
return voiceCatalogEntry(provider);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The deployment's self-hosted transcription server, when the owner set one up. */
|
export function toVoiceStatus(cred: { provider: string; voiceId: string } | null): VoiceStatus {
|
||||||
export async function mediaTranscriptionSettings(deps: VoiceDeps) {
|
|
||||||
return (await loadMediaSettings(deps).catch(() => null))?.transcription ?? null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function toVoiceStatus(
|
|
||||||
cred: { provider: string; voiceId: string } | null,
|
|
||||||
options: { mediaTranscription?: boolean } = {},
|
|
||||||
): VoiceStatus {
|
|
||||||
const entry = cred ? catalogEntry(cred.provider) : undefined;
|
const entry = cred ? catalogEntry(cred.provider) : undefined;
|
||||||
return {
|
return {
|
||||||
configured: Boolean(cred),
|
configured: Boolean(cred),
|
||||||
ready: Boolean(cred?.voiceId),
|
ready: Boolean(cred?.voiceId),
|
||||||
transcribe: Boolean(entry?.transcribe && cred) || Boolean(options.mediaTranscription),
|
transcribe: Boolean(entry?.transcribe && cred),
|
||||||
provider: cred?.provider ?? null,
|
provider: cred?.provider ?? null,
|
||||||
voiceId: cred?.voiceId ?? "",
|
voiceId: cred?.voiceId ?? "",
|
||||||
};
|
};
|
||||||
@ -287,22 +277,9 @@ export async function transcribeVoice(
|
|||||||
input: { audio: Uint8Array; mimeType: string; signal?: AbortSignal },
|
input: { audio: Uint8Array; mimeType: string; signal?: AbortSignal },
|
||||||
) {
|
) {
|
||||||
const loaded = await loadDefaultVoiceCredential(deps, actor);
|
const loaded = await loadDefaultVoiceCredential(deps, actor);
|
||||||
const provider = loaded ? createVoiceProvider(loaded.cred.provider) : null;
|
if (!loaded) throw new NoVoiceConfigured("key");
|
||||||
if (!loaded || !provider?.transcribe) {
|
const provider = createVoiceProvider(loaded.cred.provider);
|
||||||
// Without a voice provider that transcribes, the self-hosted media server can.
|
if (!provider.transcribe) {
|
||||||
const server = await mediaTranscriptionSettings(deps);
|
|
||||||
if (server) {
|
|
||||||
if (input.audio.byteLength === 0 || input.audio.byteLength > MAX_TRANSCRIBE_BYTES) {
|
|
||||||
throw new ORPCError("BAD_REQUEST", { message: "That recording is empty or too large." });
|
|
||||||
}
|
|
||||||
const transcript = await createOpenAiCompatibleTranscriptionProvider(server).transcribe({
|
|
||||||
audio: input.audio,
|
|
||||||
mimeType: input.mimeType || "audio/webm",
|
|
||||||
signal: input.signal,
|
|
||||||
});
|
|
||||||
return { text: transcript.text };
|
|
||||||
}
|
|
||||||
if (!loaded) throw new NoVoiceConfigured("key");
|
|
||||||
throw new ORPCError("BAD_REQUEST", {
|
throw new ORPCError("BAD_REQUEST", {
|
||||||
message: "This voice provider does not transcribe audio. Use on-device dictation instead.",
|
message: "This voice provider does not transcribe audio. Use on-device dictation instead.",
|
||||||
});
|
});
|
||||||
|
|||||||
@ -1,155 +0,0 @@
|
|||||||
import http from "node:http";
|
|
||||||
import path from "node:path";
|
|
||||||
import { _electron as electron, expect, test } from "@playwright/test";
|
|
||||||
import type { RakazoDesktop, RakazoDesktopBrowserPreviewState } from "@rakazo/contracts";
|
|
||||||
|
|
||||||
const fixture = `<!doctype html>
|
|
||||||
<html lang="en">
|
|
||||||
<head><meta charset="utf-8"><title>Rakazo desktop browser</title></head>
|
|
||||||
<body><main>App with a browser panel</main></body>
|
|
||||||
</html>`;
|
|
||||||
|
|
||||||
type Bridge = NonNullable<RakazoDesktop["browserPreview"]>;
|
|
||||||
|
|
||||||
test("the in-app browser is a native view the app window lays over its panel", async () => {
|
|
||||||
const pages = http.createServer((req, res) => {
|
|
||||||
res.setHeader("content-type", "text/html");
|
|
||||||
if (req.url === "/second") {
|
|
||||||
res.end("<!doctype html><title>Second page</title><p>second</p>");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
res.end(`<!doctype html><title>First page</title>
|
|
||||||
<a id="next" href="/second">next</a>
|
|
||||||
<a id="popup" href="/second" target="_blank">popup</a>
|
|
||||||
<a id="file" href="file:///etc/hosts">file</a>`);
|
|
||||||
});
|
|
||||||
await new Promise<void>((resolve) => pages.listen(0, "127.0.0.1", resolve));
|
|
||||||
const address = pages.address();
|
|
||||||
if (!address || typeof address === "string") throw new Error("No fixture server");
|
|
||||||
const origin = `http://127.0.0.1:${address.port}`;
|
|
||||||
|
|
||||||
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("App with a browser panel")).toBeVisible();
|
|
||||||
|
|
||||||
const call = <K extends keyof Bridge>(method: K, ...args: unknown[]) =>
|
|
||||||
page.evaluate(
|
|
||||||
([name, values]) => {
|
|
||||||
const bridge = (window as typeof window & { rakazoDesktop?: RakazoDesktop }).rakazoDesktop
|
|
||||||
?.browserPreview as unknown as Record<
|
|
||||||
string,
|
|
||||||
(...input: unknown[]) => Promise<unknown>
|
|
||||||
>;
|
|
||||||
return bridge[name as string]!(...(values as unknown[]));
|
|
||||||
},
|
|
||||||
[method, args] as const,
|
|
||||||
);
|
|
||||||
const state = () => call("state") as Promise<RakazoDesktopBrowserPreviewState>;
|
|
||||||
const view = () =>
|
|
||||||
app.evaluate(({ BrowserWindow }) => {
|
|
||||||
const win = BrowserWindow.getAllWindows()[0]!;
|
|
||||||
const child = win.contentView.children[0] as
|
|
||||||
| (Electron.View & { webContents?: Electron.WebContents })
|
|
||||||
| undefined;
|
|
||||||
return child
|
|
||||||
? {
|
|
||||||
count: win.contentView.children.length,
|
|
||||||
visible: child.getVisible(),
|
|
||||||
bounds: child.getBounds(),
|
|
||||||
url: child.webContents?.getURL(),
|
|
||||||
partition: child.webContents?.session === win.webContents.session,
|
|
||||||
sandbox: child.webContents?.getLastWebPreferences()?.sandbox,
|
|
||||||
nodeIntegration: child.webContents?.getLastWebPreferences()?.nodeIntegration,
|
|
||||||
}
|
|
||||||
: null;
|
|
||||||
});
|
|
||||||
|
|
||||||
// Pages load in a native view in their own session, never the app's.
|
|
||||||
await call("navigate", `${origin}/`);
|
|
||||||
await expect.poll(async () => (await state()).title).toBe("First page");
|
|
||||||
expect(await view()).toMatchObject({
|
|
||||||
count: 1,
|
|
||||||
visible: false,
|
|
||||||
url: `${origin}/`,
|
|
||||||
partition: false,
|
|
||||||
sandbox: true,
|
|
||||||
nodeIntegration: false,
|
|
||||||
});
|
|
||||||
|
|
||||||
await call("show", { x: 400, y: 50, width: 300, height: 200 });
|
|
||||||
expect(await view()).toMatchObject({
|
|
||||||
visible: true,
|
|
||||||
bounds: { x: 400, y: 50, width: 300, height: 200 },
|
|
||||||
});
|
|
||||||
// Renderer bounds outside the window are clamped to it.
|
|
||||||
await call("show", { x: -10, y: -10, width: 100_000, height: 100_000 });
|
|
||||||
const [width, height] = await app.evaluate(({ BrowserWindow }) =>
|
|
||||||
BrowserWindow.getAllWindows()[0]!.getContentSize(),
|
|
||||||
);
|
|
||||||
expect((await view())?.bounds).toEqual({ x: 0, y: 0, width, height });
|
|
||||||
|
|
||||||
const contents = () =>
|
|
||||||
app.evaluate(({ BrowserWindow }, script) => {
|
|
||||||
const win = BrowserWindow.getAllWindows()[0]!;
|
|
||||||
const child = win.contentView.children[0] as Electron.View & {
|
|
||||||
webContents: Electron.WebContents;
|
|
||||||
};
|
|
||||||
return child.webContents.executeJavaScript(script, true);
|
|
||||||
}, "document.getElementById('next').click()");
|
|
||||||
await contents();
|
|
||||||
await expect.poll(async () => (await state()).title).toBe("Second page");
|
|
||||||
expect(await state()).toMatchObject({ canGoBack: true, canGoForward: false, error: null });
|
|
||||||
|
|
||||||
await call("back");
|
|
||||||
await expect.poll(async () => (await state()).title).toBe("First page");
|
|
||||||
expect(await state()).toMatchObject({ canGoForward: true });
|
|
||||||
await call("forward");
|
|
||||||
await expect.poll(async () => (await state()).url).toBe(`${origin}/second`);
|
|
||||||
|
|
||||||
// New-window links stay in the one view; file links never load.
|
|
||||||
await call("back");
|
|
||||||
await expect.poll(async () => (await state()).title).toBe("First page");
|
|
||||||
await app.evaluate(({ BrowserWindow }) => {
|
|
||||||
const child = BrowserWindow.getAllWindows()[0]!.contentView.children[0] as Electron.View & {
|
|
||||||
webContents: Electron.WebContents;
|
|
||||||
};
|
|
||||||
return child.webContents.executeJavaScript("document.getElementById('popup').click()", true);
|
|
||||||
});
|
|
||||||
await expect.poll(async () => (await state()).url).toBe(`${origin}/second`);
|
|
||||||
expect(await app.evaluate(({ BrowserWindow }) => BrowserWindow.getAllWindows().length)).toBe(1);
|
|
||||||
await call("back");
|
|
||||||
await expect.poll(async () => (await state()).title).toBe("First page");
|
|
||||||
await app.evaluate(({ BrowserWindow }) => {
|
|
||||||
const child = BrowserWindow.getAllWindows()[0]!.contentView.children[0] as Electron.View & {
|
|
||||||
webContents: Electron.WebContents;
|
|
||||||
};
|
|
||||||
return child.webContents.executeJavaScript("document.getElementById('file').click()", true);
|
|
||||||
});
|
|
||||||
await page.waitForTimeout(300);
|
|
||||||
expect((await state()).url).toBe(`${origin}/`);
|
|
||||||
|
|
||||||
await expect(call("navigate", "file:///etc/hosts")).rejects.toThrow(/http/);
|
|
||||||
|
|
||||||
// A dev server that is not running yet shows as an error, then recovers on reload.
|
|
||||||
pages.close();
|
|
||||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
|
||||||
await call("navigate", `${origin}/gone`);
|
|
||||||
await expect.poll(async () => (await state()).error).toMatch(/ERR_CONNECTION_REFUSED/);
|
|
||||||
|
|
||||||
await call("hide");
|
|
||||||
expect((await view())?.visible).toBe(false);
|
|
||||||
} finally {
|
|
||||||
await app.close();
|
|
||||||
pages.closeAllConnections();
|
|
||||||
pages.close();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
@ -42,7 +42,6 @@ test("launches with a narrow preload bridge and an isolated renderer", async ()
|
|||||||
});
|
});
|
||||||
|
|
||||||
expect(renderer.bridgeKeys).toEqual([
|
expect(renderer.bridgeKeys).toEqual([
|
||||||
"browserPreview",
|
|
||||||
"files",
|
"files",
|
||||||
"localSettings",
|
"localSettings",
|
||||||
"oauth",
|
"oauth",
|
||||||
|
|||||||
@ -1,96 +0,0 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
|
||||||
import {
|
|
||||||
previewBounds,
|
|
||||||
previewLoadError,
|
|
||||||
previewShortcut,
|
|
||||||
previewUrl,
|
|
||||||
} from "./browser-preview-policy.js";
|
|
||||||
|
|
||||||
describe("previewUrl", () => {
|
|
||||||
it("accepts only http(s) without credentials", () => {
|
|
||||||
expect(previewUrl("http://localhost:5173")).toBe("http://localhost:5173/");
|
|
||||||
expect(previewUrl("https://example.com/a?b=1")).toBe("https://example.com/a?b=1");
|
|
||||||
for (const value of [
|
|
||||||
"file:///etc/hosts",
|
|
||||||
"javascript:alert(1)",
|
|
||||||
"chrome://settings",
|
|
||||||
"https://user:fake-password@example.com",
|
|
||||||
"not a url",
|
|
||||||
42,
|
|
||||||
`https://example.com/${"a".repeat(9000)}`,
|
|
||||||
]) {
|
|
||||||
expect(previewUrl(value)).toBeNull();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("previewBounds", () => {
|
|
||||||
const content = { width: 1200, height: 800 };
|
|
||||||
|
|
||||||
it("rounds and applies the app zoom factor", () => {
|
|
||||||
expect(previewBounds({ x: 800.4, y: 40.6, width: 399.5, height: 700 }, content)).toEqual({
|
|
||||||
x: 800,
|
|
||||||
y: 41,
|
|
||||||
width: 400,
|
|
||||||
height: 700,
|
|
||||||
});
|
|
||||||
expect(previewBounds({ x: 100, y: 10, width: 200, height: 100 }, content, 1.25)).toEqual({
|
|
||||||
x: 125,
|
|
||||||
y: 13,
|
|
||||||
width: 250,
|
|
||||||
height: 125,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("clamps to the window and rejects empty or malformed rectangles", () => {
|
|
||||||
expect(previewBounds({ x: -50, y: -10, width: 5000, height: 5000 }, content)).toEqual({
|
|
||||||
x: 0,
|
|
||||||
y: 0,
|
|
||||||
width: 1200,
|
|
||||||
height: 800,
|
|
||||||
});
|
|
||||||
expect(previewBounds({ x: 1300, y: 0, width: 100, height: 100 }, content)).toBeNull();
|
|
||||||
expect(previewBounds({ x: 0, y: 0, width: 0, height: 100 }, content)).toBeNull();
|
|
||||||
expect(previewBounds({ x: "1", y: 0, width: 1, height: 1 }, content)).toBeNull();
|
|
||||||
expect(previewBounds({ x: Number.NaN, y: 0, width: 1, height: 1 }, content)).toBeNull();
|
|
||||||
expect(previewBounds(null, content)).toBeNull();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("previewShortcut", () => {
|
|
||||||
const key = (overrides: Partial<Parameters<typeof previewShortcut>[0]>) => ({
|
|
||||||
type: "keyDown",
|
|
||||||
key: "r",
|
|
||||||
control: false,
|
|
||||||
meta: false,
|
|
||||||
alt: false,
|
|
||||||
shift: false,
|
|
||||||
...overrides,
|
|
||||||
});
|
|
||||||
|
|
||||||
it("maps platform browser shortcuts", () => {
|
|
||||||
expect(previewShortcut(key({ meta: true }), "darwin")).toBe("reload");
|
|
||||||
expect(previewShortcut(key({ meta: true, shift: true, key: "R" }), "darwin")).toBe(
|
|
||||||
"hard-reload",
|
|
||||||
);
|
|
||||||
expect(previewShortcut(key({ control: true }), "linux")).toBe("reload");
|
|
||||||
expect(previewShortcut(key({ meta: true, key: "[" }), "darwin")).toBe("back");
|
|
||||||
expect(previewShortcut(key({ alt: true, key: "ArrowRight" }), "win32")).toBe("forward");
|
|
||||||
expect(previewShortcut(key({ key: "F12" }), "linux")).toBe("devtools");
|
|
||||||
expect(previewShortcut(key({ key: "F5" }), "win32")).toBe("reload");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("leaves typing alone", () => {
|
|
||||||
expect(previewShortcut(key({}), "darwin")).toBeNull();
|
|
||||||
expect(previewShortcut(key({ control: true }), "darwin")).toBeNull();
|
|
||||||
expect(previewShortcut(key({ type: "keyUp", meta: true }), "darwin")).toBeNull();
|
|
||||||
expect(previewShortcut(key({ key: "ArrowLeft" }), "linux")).toBeNull();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("previewLoadError", () => {
|
|
||||||
it("ignores superseded navigations", () => {
|
|
||||||
expect(previewLoadError(-3, "ERR_ABORTED")).toBeNull();
|
|
||||||
expect(previewLoadError(-102, "ERR_CONNECTION_REFUSED")).toBe("ERR_CONNECTION_REFUSED");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@ -1,76 +0,0 @@
|
|||||||
import type { RakazoDesktopBrowserBounds } from "@rakazo/contracts";
|
|
||||||
|
|
||||||
/** Only plain http(s) pages load in the in-app browser; no files, schemes or URL credentials. */
|
|
||||||
export function previewUrl(value: unknown): string | null {
|
|
||||||
if (typeof value !== "string" || value.length > 8192) return null;
|
|
||||||
try {
|
|
||||||
const url = new URL(value);
|
|
||||||
if (url.protocol !== "http:" && url.protocol !== "https:") return null;
|
|
||||||
if (url.username || url.password) return null;
|
|
||||||
return url.toString();
|
|
||||||
} catch {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Renderer-reported CSS-pixel bounds, converted to window DIPs and clamped to the content area
|
|
||||||
* so a compromised renderer cannot place the view outside the panel it reserved.
|
|
||||||
*/
|
|
||||||
export function previewBounds(
|
|
||||||
value: unknown,
|
|
||||||
content: { width: number; height: number },
|
|
||||||
zoomFactor = 1,
|
|
||||||
): RakazoDesktopBrowserBounds | null {
|
|
||||||
if (!value || typeof value !== "object") return null;
|
|
||||||
const raw = value as Record<string, unknown>;
|
|
||||||
const numbers = [raw.x, raw.y, raw.width, raw.height];
|
|
||||||
if (!numbers.every((n) => typeof n === "number" && Number.isFinite(n))) return null;
|
|
||||||
const scale = Number.isFinite(zoomFactor) && zoomFactor > 0 ? zoomFactor : 1;
|
|
||||||
const [x, y, width, height] = (numbers as number[]).map((n) => Math.round(n * scale)) as [
|
|
||||||
number,
|
|
||||||
number,
|
|
||||||
number,
|
|
||||||
number,
|
|
||||||
];
|
|
||||||
const left = Math.min(Math.max(0, x), content.width);
|
|
||||||
const top = Math.min(Math.max(0, y), content.height);
|
|
||||||
const right = Math.min(Math.max(left, x + width), content.width);
|
|
||||||
const bottom = Math.min(Math.max(top, y + height), content.height);
|
|
||||||
if (right - left < 1 || bottom - top < 1) return null;
|
|
||||||
return { x: left, y: top, width: right - left, height: bottom - top };
|
|
||||||
}
|
|
||||||
|
|
||||||
export type PreviewShortcut = "reload" | "hard-reload" | "back" | "forward" | "devtools";
|
|
||||||
|
|
||||||
/** Browser keyboard shortcuts while focus is inside the page. */
|
|
||||||
export function previewShortcut(
|
|
||||||
input: {
|
|
||||||
type: string;
|
|
||||||
key: string;
|
|
||||||
control: boolean;
|
|
||||||
meta: boolean;
|
|
||||||
alt: boolean;
|
|
||||||
shift: boolean;
|
|
||||||
},
|
|
||||||
platform: string,
|
|
||||||
): PreviewShortcut | null {
|
|
||||||
if (input.type !== "keyDown") return null;
|
|
||||||
const mod = platform === "darwin" ? input.meta : input.control;
|
|
||||||
const key = input.key.length === 1 ? input.key.toLowerCase() : input.key;
|
|
||||||
if (key === "F5") return input.shift ? "hard-reload" : "reload";
|
|
||||||
if (key === "F12") return "devtools";
|
|
||||||
if (mod && key === "r") return input.shift ? "hard-reload" : "reload";
|
|
||||||
if (mod && input.alt && key === "i") return "devtools";
|
|
||||||
if (platform === "darwin" ? input.meta && !input.alt : input.alt && !input.control) {
|
|
||||||
if (key === (platform === "darwin" ? "[" : "ArrowLeft")) return "back";
|
|
||||||
if (key === (platform === "darwin" ? "]" : "ArrowRight")) return "forward";
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Load failures worth showing; an aborted load (-3) is a superseded navigation, not an error. */
|
|
||||||
export function previewLoadError(errorCode: number, description: string): string | null {
|
|
||||||
if (errorCode === -3 || errorCode === 0) return null;
|
|
||||||
return description || `Load failed (${errorCode})`;
|
|
||||||
}
|
|
||||||
@ -1,189 +0,0 @@
|
|||||||
import type { RakazoDesktopBrowserPreviewState } from "@rakazo/contracts";
|
|
||||||
import { type BrowserWindow, WebContentsView } from "electron";
|
|
||||||
import {
|
|
||||||
previewBounds,
|
|
||||||
previewLoadError,
|
|
||||||
previewShortcut,
|
|
||||||
previewUrl,
|
|
||||||
} from "./browser-preview-policy.js";
|
|
||||||
|
|
||||||
/** Pages share one persistent profile, separate from the app's own session and cookies. */
|
|
||||||
const PREVIEW_PARTITION = "persist:rakazo-browser";
|
|
||||||
export const PREVIEW_STATE_CHANNEL = "desktop.browserPreview.state";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The in-app browser's native page: one WebContentsView laid over the panel the app window
|
|
||||||
* reserves. It belongs to one app window at a time and is torn down with it.
|
|
||||||
*/
|
|
||||||
export class BrowserPreviewController {
|
|
||||||
private host: BrowserWindow | null = null;
|
|
||||||
private view: WebContentsView | null = null;
|
|
||||||
private error: string | null = null;
|
|
||||||
|
|
||||||
state(win: BrowserWindow): RakazoDesktopBrowserPreviewState {
|
|
||||||
const contents = this.host === win ? this.view?.webContents : undefined;
|
|
||||||
if (!contents || contents.isDestroyed()) {
|
|
||||||
return {
|
|
||||||
url: "",
|
|
||||||
title: "",
|
|
||||||
loading: false,
|
|
||||||
canGoBack: false,
|
|
||||||
canGoForward: false,
|
|
||||||
error: null,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
const url = contents.getURL();
|
|
||||||
return {
|
|
||||||
url,
|
|
||||||
title: contents.getTitle() === url ? "" : contents.getTitle(),
|
|
||||||
loading: contents.isLoading(),
|
|
||||||
canGoBack: contents.navigationHistory.canGoBack(),
|
|
||||||
canGoForward: contents.navigationHistory.canGoForward(),
|
|
||||||
error: this.error,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
show(win: BrowserWindow, rawBounds: unknown) {
|
|
||||||
const view = this.viewFor(win);
|
|
||||||
const [width, height] = win.getContentSize();
|
|
||||||
const bounds = previewBounds(
|
|
||||||
rawBounds,
|
|
||||||
{ width: width ?? 0, height: height ?? 0 },
|
|
||||||
win.webContents.getZoomFactor(),
|
|
||||||
);
|
|
||||||
if (!bounds) {
|
|
||||||
view.setVisible(false);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
view.setBounds(bounds);
|
|
||||||
view.setVisible(true);
|
|
||||||
}
|
|
||||||
|
|
||||||
hide(win: BrowserWindow) {
|
|
||||||
if (this.host === win) this.view?.setVisible(false);
|
|
||||||
}
|
|
||||||
|
|
||||||
async navigate(win: BrowserWindow, rawUrl: unknown) {
|
|
||||||
const url = previewUrl(rawUrl);
|
|
||||||
if (!url) throw new Error("Only http(s) pages can open here.");
|
|
||||||
this.error = null;
|
|
||||||
// Failures surface through did-fail-load as page state, not as a rejected call.
|
|
||||||
await this.viewFor(win)
|
|
||||||
.webContents.loadURL(url)
|
|
||||||
.catch(() => undefined);
|
|
||||||
}
|
|
||||||
|
|
||||||
back(win: BrowserWindow) {
|
|
||||||
const history = this.contentsFor(win)?.navigationHistory;
|
|
||||||
if (history?.canGoBack()) history.goBack();
|
|
||||||
}
|
|
||||||
|
|
||||||
forward(win: BrowserWindow) {
|
|
||||||
const history = this.contentsFor(win)?.navigationHistory;
|
|
||||||
if (history?.canGoForward()) history.goForward();
|
|
||||||
}
|
|
||||||
|
|
||||||
reload(win: BrowserWindow, ignoreCache = false) {
|
|
||||||
const contents = this.contentsFor(win);
|
|
||||||
if (!contents?.getURL()) return;
|
|
||||||
this.error = null;
|
|
||||||
if (ignoreCache) contents.reloadIgnoringCache();
|
|
||||||
else contents.reload();
|
|
||||||
}
|
|
||||||
|
|
||||||
stop(win: BrowserWindow) {
|
|
||||||
this.contentsFor(win)?.stop();
|
|
||||||
}
|
|
||||||
|
|
||||||
openDevTools(win: BrowserWindow) {
|
|
||||||
const contents = this.contentsFor(win);
|
|
||||||
if (contents?.getURL()) contents.openDevTools({ mode: "detach" });
|
|
||||||
}
|
|
||||||
|
|
||||||
private contentsFor(win: BrowserWindow) {
|
|
||||||
const contents = this.host === win ? this.view?.webContents : undefined;
|
|
||||||
return contents && !contents.isDestroyed() ? contents : undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
private viewFor(win: BrowserWindow): WebContentsView {
|
|
||||||
if (this.host === win && this.view && !this.view.webContents.isDestroyed()) return this.view;
|
|
||||||
this.destroy();
|
|
||||||
const view = new WebContentsView({
|
|
||||||
webPreferences: {
|
|
||||||
partition: PREVIEW_PARTITION,
|
|
||||||
sandbox: true,
|
|
||||||
contextIsolation: true,
|
|
||||||
nodeIntegration: false,
|
|
||||||
webSecurity: true,
|
|
||||||
spellcheck: false,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
view.setVisible(false);
|
|
||||||
const contents = view.webContents;
|
|
||||||
const publish = () => {
|
|
||||||
if (!win.isDestroyed()) win.webContents.send(PREVIEW_STATE_CHANNEL, this.state(win));
|
|
||||||
};
|
|
||||||
// One tab: links that want a new window open here instead.
|
|
||||||
contents.setWindowOpenHandler(({ url }) => {
|
|
||||||
const target = previewUrl(url);
|
|
||||||
if (target) void contents.loadURL(target).catch(() => undefined);
|
|
||||||
return { action: "deny" };
|
|
||||||
});
|
|
||||||
contents.on("will-navigate", (event, url) => {
|
|
||||||
if (!previewUrl(url)) event.preventDefault();
|
|
||||||
});
|
|
||||||
contents.on("will-redirect", (event, url) => {
|
|
||||||
if (!previewUrl(url)) event.preventDefault();
|
|
||||||
});
|
|
||||||
contents.on("did-start-loading", publish);
|
|
||||||
contents.on("did-stop-loading", publish);
|
|
||||||
contents.on("did-navigate", () => {
|
|
||||||
this.error = null;
|
|
||||||
publish();
|
|
||||||
});
|
|
||||||
contents.on("did-navigate-in-page", publish);
|
|
||||||
contents.on("page-title-updated", publish);
|
|
||||||
contents.on("did-fail-load", (_event, code, description, _url, isMainFrame) => {
|
|
||||||
if (!isMainFrame) return;
|
|
||||||
const error = previewLoadError(code, description);
|
|
||||||
if (error === null) return;
|
|
||||||
this.error = error;
|
|
||||||
publish();
|
|
||||||
});
|
|
||||||
contents.on("render-process-gone", (_event, details) => {
|
|
||||||
this.error = details.reason === "clean-exit" ? null : "The page stopped responding.";
|
|
||||||
publish();
|
|
||||||
});
|
|
||||||
contents.on("before-input-event", (event, input) => {
|
|
||||||
const shortcut = previewShortcut(input, process.platform);
|
|
||||||
if (!shortcut) return;
|
|
||||||
event.preventDefault();
|
|
||||||
if (shortcut === "reload" || shortcut === "hard-reload") {
|
|
||||||
this.reload(win, shortcut === "hard-reload");
|
|
||||||
} else if (shortcut === "back") {
|
|
||||||
this.back(win);
|
|
||||||
} else if (shortcut === "forward") {
|
|
||||||
this.forward(win);
|
|
||||||
} else {
|
|
||||||
this.openDevTools(win);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
win.contentView.addChildView(view);
|
|
||||||
win.once("closed", () => {
|
|
||||||
if (this.host === win) this.destroy();
|
|
||||||
});
|
|
||||||
this.host = win;
|
|
||||||
this.view = view;
|
|
||||||
return view;
|
|
||||||
}
|
|
||||||
|
|
||||||
private destroy() {
|
|
||||||
const { host, view } = this;
|
|
||||||
this.host = null;
|
|
||||||
this.view = null;
|
|
||||||
this.error = null;
|
|
||||||
if (!view) return;
|
|
||||||
if (host && !host.isDestroyed()) host.contentView.removeChildView(view);
|
|
||||||
if (!view.webContents.isDestroyed()) view.webContents.close();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -21,7 +21,6 @@ import {
|
|||||||
LAUNCH_CHECK_DELAY_MS,
|
LAUNCH_CHECK_DELAY_MS,
|
||||||
} from "./auto-update.js";
|
} from "./auto-update.js";
|
||||||
import { openBrowserAuth } from "./browser-auth.js";
|
import { openBrowserAuth } from "./browser-auth.js";
|
||||||
import { BrowserPreviewController } from "./browser-preview.js";
|
|
||||||
import { DOCKER_INSTALL_LINKS, isDesktopSetupLink, runDocker } from "./docker-cli.js";
|
import { DOCKER_INSTALL_LINKS, isDesktopSetupLink, runDocker } from "./docker-cli.js";
|
||||||
import { requestLocalSettings } from "./local-settings.js";
|
import { requestLocalSettings } from "./local-settings.js";
|
||||||
import {
|
import {
|
||||||
@ -1131,35 +1130,6 @@ app.whenReady().then(async () => {
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
ipcMain.handle("desktop.platform", () => process.platform);
|
ipcMain.handle("desktop.platform", () => process.platform);
|
||||||
const browserPreview = new BrowserPreviewController();
|
|
||||||
// The in-app browser answers only the app window's own top frame, never a page inside it.
|
|
||||||
const previewHost = (event: Electron.IpcMainInvokeEvent) => {
|
|
||||||
if (!fromMainWindow(event) || event.senderFrame !== event.sender.mainFrame) {
|
|
||||||
throw new Error("The in-app browser is not available here.");
|
|
||||||
}
|
|
||||||
return mainWindow!;
|
|
||||||
};
|
|
||||||
ipcMain.handle("desktop.browserPreview.state", (event) =>
|
|
||||||
browserPreview.state(previewHost(event)),
|
|
||||||
);
|
|
||||||
ipcMain.handle("desktop.browserPreview.show", (event, bounds: unknown) =>
|
|
||||||
browserPreview.show(previewHost(event), bounds),
|
|
||||||
);
|
|
||||||
ipcMain.handle("desktop.browserPreview.hide", (event) => browserPreview.hide(previewHost(event)));
|
|
||||||
ipcMain.handle("desktop.browserPreview.navigate", (event, url: unknown) =>
|
|
||||||
browserPreview.navigate(previewHost(event), url),
|
|
||||||
);
|
|
||||||
ipcMain.handle("desktop.browserPreview.back", (event) => browserPreview.back(previewHost(event)));
|
|
||||||
ipcMain.handle("desktop.browserPreview.forward", (event) =>
|
|
||||||
browserPreview.forward(previewHost(event)),
|
|
||||||
);
|
|
||||||
ipcMain.handle("desktop.browserPreview.reload", (event) =>
|
|
||||||
browserPreview.reload(previewHost(event)),
|
|
||||||
);
|
|
||||||
ipcMain.handle("desktop.browserPreview.stop", (event) => browserPreview.stop(previewHost(event)));
|
|
||||||
ipcMain.handle("desktop.browserPreview.openDevTools", (event) =>
|
|
||||||
browserPreview.openDevTools(previewHost(event)),
|
|
||||||
);
|
|
||||||
ipcMain.handle("desktop.files.pickDirectory", async (event) => {
|
ipcMain.handle("desktop.files.pickDirectory", async (event) => {
|
||||||
if (!fromMainWindow(event)) return null;
|
if (!fromMainWindow(event)) return null;
|
||||||
const result = await dialog.showOpenDialog(windowFrom(event)!, {
|
const result = await dialog.showOpenDialog(windowFrom(event)!, {
|
||||||
|
|||||||
@ -21,22 +21,6 @@ contextBridge.exposeInMainWorld("rakazoDesktop", {
|
|||||||
download: () => ipcRenderer.invoke("desktop.update.download"),
|
download: () => ipcRenderer.invoke("desktop.update.download"),
|
||||||
install: () => ipcRenderer.invoke("desktop.update.install"),
|
install: () => ipcRenderer.invoke("desktop.update.install"),
|
||||||
},
|
},
|
||||||
browserPreview: {
|
|
||||||
state: () => ipcRenderer.invoke("desktop.browserPreview.state"),
|
|
||||||
show: (bounds) => ipcRenderer.invoke("desktop.browserPreview.show", bounds),
|
|
||||||
hide: () => ipcRenderer.invoke("desktop.browserPreview.hide"),
|
|
||||||
navigate: (url) => ipcRenderer.invoke("desktop.browserPreview.navigate", url),
|
|
||||||
back: () => ipcRenderer.invoke("desktop.browserPreview.back"),
|
|
||||||
forward: () => ipcRenderer.invoke("desktop.browserPreview.forward"),
|
|
||||||
reload: () => ipcRenderer.invoke("desktop.browserPreview.reload"),
|
|
||||||
stop: () => ipcRenderer.invoke("desktop.browserPreview.stop"),
|
|
||||||
openDevTools: () => ipcRenderer.invoke("desktop.browserPreview.openDevTools"),
|
|
||||||
onState: (listener) => {
|
|
||||||
const handler = (_event, state) => listener(state);
|
|
||||||
ipcRenderer.on("desktop.browserPreview.state", handler);
|
|
||||||
return () => ipcRenderer.off("desktop.browserPreview.state", handler);
|
|
||||||
},
|
|
||||||
},
|
|
||||||
oauth: {
|
oauth: {
|
||||||
open: (url) => ipcRenderer.invoke("desktop.oauth.open", url),
|
open: (url) => ipcRenderer.invoke("desktop.oauth.open", url),
|
||||||
cancel: (url) => ipcRenderer.invoke("desktop.oauth.cancel", url),
|
cancel: (url) => ipcRenderer.invoke("desktop.oauth.cancel", url),
|
||||||
|
|||||||
@ -32,7 +32,6 @@ describe("desktop preload bridge", () => {
|
|||||||
expect(globalName).toBe("rakazoDesktop");
|
expect(globalName).toBe("rakazoDesktop");
|
||||||
expect(bridge.platform).toBe("linux");
|
expect(bridge.platform).toBe("linux");
|
||||||
expect(Object.keys(bridge).sort()).toEqual([
|
expect(Object.keys(bridge).sort()).toEqual([
|
||||||
"browserPreview",
|
|
||||||
"files",
|
"files",
|
||||||
"localSettings",
|
"localSettings",
|
||||||
"oauth",
|
"oauth",
|
||||||
@ -47,18 +46,6 @@ describe("desktop preload bridge", () => {
|
|||||||
"toggleMaximize",
|
"toggleMaximize",
|
||||||
]);
|
]);
|
||||||
expect(Object.keys(bridge.update).sort()).toEqual(["check", "download", "install", "state"]);
|
expect(Object.keys(bridge.update).sort()).toEqual(["check", "download", "install", "state"]);
|
||||||
expect(Object.keys(bridge.browserPreview ?? {}).sort()).toEqual([
|
|
||||||
"back",
|
|
||||||
"forward",
|
|
||||||
"hide",
|
|
||||||
"navigate",
|
|
||||||
"onState",
|
|
||||||
"openDevTools",
|
|
||||||
"reload",
|
|
||||||
"show",
|
|
||||||
"state",
|
|
||||||
"stop",
|
|
||||||
]);
|
|
||||||
|
|
||||||
await bridge.files?.pickDirectory();
|
await bridge.files?.pickDirectory();
|
||||||
await bridge.oauth.open?.("https://provider.example.com/authorize");
|
await bridge.oauth.open?.("https://provider.example.com/authorize");
|
||||||
@ -71,8 +58,6 @@ describe("desktop preload bridge", () => {
|
|||||||
await bridge.update.check();
|
await bridge.update.check();
|
||||||
await bridge.update.download();
|
await bridge.update.download();
|
||||||
await bridge.update.install();
|
await bridge.update.install();
|
||||||
await bridge.browserPreview?.navigate("http://localhost:5173/");
|
|
||||||
await bridge.browserPreview?.show({ x: 1, y: 2, width: 3, height: 4 });
|
|
||||||
expect(invoke.mock.calls.map(([channel]) => channel)).toEqual([
|
expect(invoke.mock.calls.map(([channel]) => channel)).toEqual([
|
||||||
"desktop.files.pickDirectory",
|
"desktop.files.pickDirectory",
|
||||||
"desktop.oauth.open",
|
"desktop.oauth.open",
|
||||||
@ -85,8 +70,6 @@ describe("desktop preload bridge", () => {
|
|||||||
"desktop.update.check",
|
"desktop.update.check",
|
||||||
"desktop.update.download",
|
"desktop.update.download",
|
||||||
"desktop.update.install",
|
"desktop.update.install",
|
||||||
"desktop.browserPreview.navigate",
|
|
||||||
"desktop.browserPreview.show",
|
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -94,7 +77,6 @@ describe("desktop preload bridge", () => {
|
|||||||
const { exposeInMainWorld } = runPreload("preload.cjs");
|
const { exposeInMainWorld } = runPreload("preload.cjs");
|
||||||
const [, bridge] = exposeInMainWorld.mock.calls[0] as [string, Record<string, unknown>];
|
const [, bridge] = exposeInMainWorld.mock.calls[0] as [string, Record<string, unknown>];
|
||||||
expect(Object.keys(bridge).sort()).toEqual([
|
expect(Object.keys(bridge).sort()).toEqual([
|
||||||
"browserPreview",
|
|
||||||
"files",
|
"files",
|
||||||
"localSettings",
|
"localSettings",
|
||||||
"oauth",
|
"oauth",
|
||||||
|
|||||||
@ -28,11 +28,7 @@ export default function NewSpace() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
router.dismissAll();
|
router.dismissAll();
|
||||||
// A new project opens in the conversation with its own main agent.
|
router.replace("/");
|
||||||
const main = space.groups.find((group) => group.kind === "main");
|
|
||||||
router.replace(
|
|
||||||
main ? { pathname: "/group-thread", params: { groupId: main.id, name: main.name } } : "/",
|
|
||||||
);
|
|
||||||
} catch (reason) {
|
} catch (reason) {
|
||||||
setError(reason instanceof Error ? reason.message : t("Could not create space"));
|
setError(reason instanceof Error ? reason.message : t("Could not create space"));
|
||||||
setPending(false);
|
setPending(false);
|
||||||
|
|||||||
@ -17,12 +17,10 @@ import {
|
|||||||
buildComposerMentionOptions,
|
buildComposerMentionOptions,
|
||||||
type ComposerMention,
|
type ComposerMention,
|
||||||
cloudAgentHttpsUrl,
|
cloudAgentHttpsUrl,
|
||||||
deriveWorkspaceFocus,
|
|
||||||
isApprovalAskBlock,
|
isApprovalAskBlock,
|
||||||
isRunTerminalEvent,
|
isRunTerminalEvent,
|
||||||
isSecretAskBlock,
|
isSecretAskBlock,
|
||||||
latestAnswerableAskMessageId,
|
latestAnswerableAskMessageId,
|
||||||
liveScreenRunId,
|
|
||||||
mentionChipKey,
|
mentionChipKey,
|
||||||
projectMessageReactions,
|
projectMessageReactions,
|
||||||
resolveComposerSendPlan,
|
resolveComposerSendPlan,
|
||||||
@ -67,21 +65,18 @@ import {
|
|||||||
type MarkdownArtifactPreviewTarget,
|
type MarkdownArtifactPreviewTarget,
|
||||||
} from "../components/markdown-artifact-preview";
|
} from "../components/markdown-artifact-preview";
|
||||||
import { NativeSymbol } from "../components/native-symbol";
|
import { NativeSymbol } from "../components/native-symbol";
|
||||||
import type {
|
|
||||||
MobileBot,
|
|
||||||
MobileGroup,
|
|
||||||
MobileMessage,
|
|
||||||
MobileMessagePage,
|
|
||||||
MobileSnapshot,
|
|
||||||
} from "../lib/api";
|
|
||||||
import {
|
import {
|
||||||
applyMobileThreadEvent,
|
applyMobileThreadEvent,
|
||||||
blockText,
|
blockText,
|
||||||
copyableMobileMessageText,
|
copyableMobileMessageText,
|
||||||
currentApiBase,
|
currentApiBase,
|
||||||
loadSessionToken,
|
loadSessionToken,
|
||||||
|
type MobileBot,
|
||||||
|
type MobileGroup,
|
||||||
|
type MobileMessage,
|
||||||
|
type MobileMessagePage,
|
||||||
|
type MobileSnapshot,
|
||||||
mergeMobileSnapshot,
|
mergeMobileSnapshot,
|
||||||
messageAttachments,
|
|
||||||
messagingProviderLabel,
|
messagingProviderLabel,
|
||||||
prependMobileMessagePage,
|
prependMobileMessagePage,
|
||||||
rpc,
|
rpc,
|
||||||
@ -293,8 +288,6 @@ function Thread() {
|
|||||||
? { botId }
|
? { botId }
|
||||||
: undefined;
|
: undefined;
|
||||||
const [snap, setSnap] = useState<MobileSnapshot | null>(null);
|
const [snap, setSnap] = useState<MobileSnapshot | null>(null);
|
||||||
/** Run that last pointed at the bot's screen, so the live screen is offered only during real screen work. */
|
|
||||||
const [screenRunId, setScreenRunId] = useState<string | null>(null);
|
|
||||||
const activeThreadId = useRef<string | undefined>(undefined);
|
const activeThreadId = useRef<string | undefined>(undefined);
|
||||||
const [draft, setDraft] = useState("");
|
const [draft, setDraft] = useState("");
|
||||||
const [mentionQuery, setMentionQuery] = useState<string | null>(null);
|
const [mentionQuery, setMentionQuery] = useState<string | null>(null);
|
||||||
@ -867,7 +860,6 @@ function Thread() {
|
|||||||
}
|
}
|
||||||
expandedHistoryThread.current = null;
|
expandedHistoryThread.current = null;
|
||||||
historyEpoch.current += 1;
|
historyEpoch.current += 1;
|
||||||
setScreenRunId(null);
|
|
||||||
const abort = new AbortController();
|
const abort = new AbortController();
|
||||||
void (async () => {
|
void (async () => {
|
||||||
// Pending search jumps load the around-page separately; avoid replacing it with latest.
|
// Pending search jumps load the around-page separately; avoid replacing it with latest.
|
||||||
@ -893,8 +885,6 @@ function Thread() {
|
|||||||
(event) => {
|
(event) => {
|
||||||
cursor = Math.max(cursor, event.seq ?? -1);
|
cursor = Math.max(cursor, event.seq ?? -1);
|
||||||
retryMs = 250;
|
retryMs = 250;
|
||||||
const screenRun = groupId ? null : liveScreenRunId(event);
|
|
||||||
if (screenRun) setScreenRunId(screenRun);
|
|
||||||
if (
|
if (
|
||||||
event.type === "thread.progress" ||
|
event.type === "thread.progress" ||
|
||||||
event.type === "agent.tool.called" ||
|
event.type === "agent.tool.called" ||
|
||||||
@ -1281,23 +1271,6 @@ function Thread() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const answerableAskMessageId = latestAnswerableAskMessageId(snap);
|
const answerableAskMessageId = latestAnswerableAskMessageId(snap);
|
||||||
const screenFocus = useMemo(() => {
|
|
||||||
const computer = snap?.computer;
|
|
||||||
if (!snap || !computer || groupId) return null;
|
|
||||||
const focus = deriveWorkspaceFocus(
|
|
||||||
{
|
|
||||||
...snap,
|
|
||||||
computer: {
|
|
||||||
kind: computer.kind ?? "",
|
|
||||||
state: computer.state,
|
|
||||||
screenAvailable: computer.screenAvailable,
|
|
||||||
takeoverRequested: computer.takeoverRequested ?? false,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{ screenRunId },
|
|
||||||
);
|
|
||||||
return focus.pane === "screen" ? focus : null;
|
|
||||||
}, [groupId, screenRunId, snap]);
|
|
||||||
const runError = snap?.run?.status === "failed" ? (snap.run.error ?? null) : null;
|
const runError = snap?.run?.status === "failed" ? (snap.run.error ?? null) : null;
|
||||||
const liveMessages = useMemo(() => [...visibleMessages].reverse(), [visibleMessages]);
|
const liveMessages = useMemo(() => [...visibleMessages].reverse(), [visibleMessages]);
|
||||||
const messagesById = useMemo(
|
const messagesById = useMemo(
|
||||||
@ -1695,43 +1668,6 @@ function Thread() {
|
|||||||
) : null}
|
) : null}
|
||||||
</View>
|
</View>
|
||||||
<View style={{ paddingBottom: keyboardVisible ? 12 : Math.max(insets.bottom + 12, 24) }}>
|
<View style={{ paddingBottom: keyboardVisible ? 12 : Math.max(insets.bottom + 12, 24) }}>
|
||||||
{screenFocus && botId ? (
|
|
||||||
<Pressable
|
|
||||||
accessibilityRole="button"
|
|
||||||
testID="live-screen-pill"
|
|
||||||
onPress={() =>
|
|
||||||
router.push({
|
|
||||||
pathname: "/computer",
|
|
||||||
params: { botId, name: displayName ?? t("Bot") },
|
|
||||||
})
|
|
||||||
}
|
|
||||||
style={({ pressed }) => ({
|
|
||||||
alignSelf: "center",
|
|
||||||
marginTop: 10,
|
|
||||||
flexDirection: "row",
|
|
||||||
alignItems: "center",
|
|
||||||
gap: 8,
|
|
||||||
borderRadius: 999,
|
|
||||||
borderWidth: 1,
|
|
||||||
borderColor: native.fillPressed,
|
|
||||||
backgroundColor: pressed ? native.fillPressed : native.fill,
|
|
||||||
paddingHorizontal: 14,
|
|
||||||
paddingVertical: 8,
|
|
||||||
})}
|
|
||||||
>
|
|
||||||
<View
|
|
||||||
style={{
|
|
||||||
width: 8,
|
|
||||||
height: 8,
|
|
||||||
borderRadius: 4,
|
|
||||||
backgroundColor: screenFocus.takeover ? tokens.warning : tokens.success,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<Text style={{ color: tokens.foreground, fontSize: 14, fontWeight: "500" }}>
|
|
||||||
{screenFocus.takeover ? t("Needs takeover") : t("Open computer")}
|
|
||||||
</Text>
|
|
||||||
</Pressable>
|
|
||||||
) : null}
|
|
||||||
{replyTarget ? (
|
{replyTarget ? (
|
||||||
<View
|
<View
|
||||||
style={{
|
style={{
|
||||||
@ -2307,11 +2243,7 @@ function previewMessageText(message: MobileMessage): string {
|
|||||||
.join(" ")
|
.join(" ")
|
||||||
.trim();
|
.trim();
|
||||||
if (text) return text;
|
if (text) return text;
|
||||||
if (
|
if (message.blocks.some((block) => block.kind === "image" || block.kind === "file")) {
|
||||||
message.blocks.some((block) =>
|
|
||||||
["image", "file", "video", "audio", "media"].includes(block.kind),
|
|
||||||
)
|
|
||||||
) {
|
|
||||||
return t("Attachment");
|
return t("Attachment");
|
||||||
}
|
}
|
||||||
return t("Message");
|
return t("Message");
|
||||||
@ -2744,7 +2676,9 @@ const MessageBubble = memo(function MessageBubble({
|
|||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
const attachments = messageAttachments(message.blocks);
|
const attachments = message.blocks.filter(
|
||||||
|
(block) => block.kind === "image" || block.kind === "file",
|
||||||
|
);
|
||||||
const caption = message.blocks
|
const caption = message.blocks
|
||||||
.flatMap((block) => {
|
.flatMap((block) => {
|
||||||
if (block.kind === "channel_message" && block.text) {
|
if (block.kind === "channel_message" && block.text) {
|
||||||
@ -2752,7 +2686,6 @@ const MessageBubble = memo(function MessageBubble({
|
|||||||
`${messagingProviderLabel(block.provider, block.transport)} · ${block.fromLabel}: ${block.text}`,
|
`${messagingProviderLabel(block.provider, block.transport)} · ${block.fromLabel}: ${block.text}`,
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
if (block.kind === "media") return [block.title];
|
|
||||||
return block.kind === "text" && block.text ? [block.text] : [];
|
return block.kind === "text" && block.text ? [block.text] : [];
|
||||||
})
|
})
|
||||||
.join("\n");
|
.join("\n");
|
||||||
@ -2864,8 +2797,7 @@ const MessageBubble = memo(function MessageBubble({
|
|||||||
fontSize: 15,
|
fontSize: 15,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{attachment.kind === "video" ? "🎬" : attachment.kind === "audio" ? "🎵" : "📎"}{" "}
|
📎 {attachment.name ?? t("File")}
|
||||||
{attachment.name ?? t("File")}
|
|
||||||
</Text>
|
</Text>
|
||||||
{attachment.size ? (
|
{attachment.size ? (
|
||||||
<Text
|
<Text
|
||||||
|
|||||||
@ -16,7 +16,6 @@ import {
|
|||||||
MAX_MOBILE_AUTH_RESPONSE_BYTES,
|
MAX_MOBILE_AUTH_RESPONSE_BYTES,
|
||||||
MAX_MOBILE_RPC_RESPONSE_BYTES,
|
MAX_MOBILE_RPC_RESPONSE_BYTES,
|
||||||
mergeMobileSnapshot,
|
mergeMobileSnapshot,
|
||||||
messageAttachments,
|
|
||||||
passwordResetCapabilities,
|
passwordResetCapabilities,
|
||||||
prependMobileMessagePage,
|
prependMobileMessagePage,
|
||||||
requestPasswordReset,
|
requestPasswordReset,
|
||||||
@ -1858,77 +1857,6 @@ describe("mobile thread event reduction", () => {
|
|||||||
).toBe("Slack · Alex: Hello from the group");
|
).toBe("Slack · Alex: Hello from the group");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("falls back to plain text and file rows for video, audio and media cards", () => {
|
|
||||||
const blocks = [
|
|
||||||
{
|
|
||||||
kind: "video" as const,
|
|
||||||
artifactId: "vid-1",
|
|
||||||
mimeType: "video/mp4",
|
|
||||||
name: "clip.mp4",
|
|
||||||
size: 10,
|
|
||||||
width: 1080,
|
|
||||||
height: 1920,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
kind: "audio" as const,
|
|
||||||
artifactId: "aud-1",
|
|
||||||
mimeType: "audio/mpeg",
|
|
||||||
name: "memo.mp3",
|
|
||||||
size: 5,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
kind: "media" as const,
|
|
||||||
jobId: "job-1",
|
|
||||||
operation: "export_video" as const,
|
|
||||||
status: "done" as const,
|
|
||||||
title: "Vertical cut",
|
|
||||||
outputs: [
|
|
||||||
{
|
|
||||||
artifactId: "out-1",
|
|
||||||
kind: "video" as const,
|
|
||||||
mimeType: "video/mp4",
|
|
||||||
name: "cut.mp4",
|
|
||||||
size: 7,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
];
|
|
||||||
expect(blockText(mobileMessage("media-1", blocks))).toBe(
|
|
||||||
"[video: clip.mp4]\n[audio: memo.mp3]\nVertical cut: done",
|
|
||||||
);
|
|
||||||
expect(messageAttachments(blocks).map((item) => [item.kind, item.artifactId])).toEqual([
|
|
||||||
["video", "vid-1"],
|
|
||||||
["audio", "aud-1"],
|
|
||||||
["video", "out-1"],
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("reads a choice card as its question and answer", () => {
|
|
||||||
const card = {
|
|
||||||
kind: "choice" as const,
|
|
||||||
step: "goal" as const,
|
|
||||||
question: "What’s one thing you want to get done in the next few weeks?",
|
|
||||||
textInput: { placeholder: "Your goal" },
|
|
||||||
options: [{ id: "skip", letter: "", label: "Skip" }],
|
|
||||||
};
|
|
||||||
expect(blockText(mobileMessage("choice-1", [card]))).toBe(card.question);
|
|
||||||
expect(
|
|
||||||
blockText(
|
|
||||||
mobileMessage("choice-2", [{ ...card, answerId: "_text", answerText: "Run a 10K" }]),
|
|
||||||
),
|
|
||||||
).toBe(`${card.question}\nRun a 10K`);
|
|
||||||
expect(blockText(mobileMessage("choice-3", [{ ...card, answerId: "skip" }]))).toBe(
|
|
||||||
`${card.question}\nSkip`,
|
|
||||||
);
|
|
||||||
const note = {
|
|
||||||
kind: "intake_note" as const,
|
|
||||||
note: "done" as const,
|
|
||||||
nextCheckInAt: null,
|
|
||||||
text: "All set. I’m here whenever you need me.",
|
|
||||||
};
|
|
||||||
expect(blockText(mobileMessage("note-1", [note]))).toBe(note.text);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("deduplicates durable messages and replaces matching transient subagent state", () => {
|
it("deduplicates durable messages and replaces matching transient subagent state", () => {
|
||||||
const initial = snapshot([
|
const initial = snapshot([
|
||||||
mobileMessage("message-1", [{ kind: "text", text: "old" }]),
|
mobileMessage("message-1", [{ kind: "text", text: "old" }]),
|
||||||
|
|||||||
@ -728,7 +728,7 @@ export type MobileBot = Pick<
|
|||||||
| "modelId"
|
| "modelId"
|
||||||
| "thinkingLevel"
|
| "thinkingLevel"
|
||||||
> &
|
> &
|
||||||
Partial<Pick<Bot, "parentBotId" | "spaceId" | "kind">>;
|
Partial<Pick<Bot, "parentBotId" | "spaceId">>;
|
||||||
|
|
||||||
export type MobileBotSection = BotSection;
|
export type MobileBotSection = BotSection;
|
||||||
|
|
||||||
@ -790,11 +790,9 @@ export type MobileSnapshot = {
|
|||||||
activeRuns?: Array<{ id: string; botId?: string; status: string }>;
|
activeRuns?: Array<{ id: string; botId?: string; status: string }>;
|
||||||
members?: MobileGroup["members"];
|
members?: MobileGroup["members"];
|
||||||
computer?: {
|
computer?: {
|
||||||
kind?: string;
|
|
||||||
state: string;
|
state: string;
|
||||||
controlHolder: string;
|
controlHolder: string;
|
||||||
screenAvailable: boolean;
|
screenAvailable: boolean;
|
||||||
takeoverRequested?: boolean;
|
|
||||||
mode: ComputerMode;
|
mode: ComputerMode;
|
||||||
busyBotName: string | null;
|
busyBotName: string | null;
|
||||||
};
|
};
|
||||||
@ -862,24 +860,6 @@ export function copyableMobileMessageText(message: MobileMessage): string {
|
|||||||
.trim();
|
.trim();
|
||||||
}
|
}
|
||||||
|
|
||||||
export type MobileAttachment = {
|
|
||||||
kind: "image" | "video" | "audio" | "file";
|
|
||||||
artifactId: string;
|
|
||||||
name: string;
|
|
||||||
mimeType: string;
|
|
||||||
size?: number;
|
|
||||||
};
|
|
||||||
|
|
||||||
/** Files a message carries, including the outputs of a finished media card, as tappable rows. */
|
|
||||||
export function messageAttachments(blocks: readonly MessageBlock[]): MobileAttachment[] {
|
|
||||||
return blocks.flatMap((block): MobileAttachment[] => {
|
|
||||||
if (block.kind === "image") return [{ ...block, kind: "image" }];
|
|
||||||
if (block.kind === "video" || block.kind === "audio" || block.kind === "file") return [block];
|
|
||||||
if (block.kind === "media") return block.outputs;
|
|
||||||
return [];
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function blockText(message: MobileMessage) {
|
export function blockText(message: MobileMessage) {
|
||||||
return message.blocks
|
return message.blocks
|
||||||
.map((block) => {
|
.map((block) => {
|
||||||
@ -894,21 +874,11 @@ export function blockText(message: MobileMessage) {
|
|||||||
if (block.kind === "child_bot") {
|
if (block.kind === "child_bot") {
|
||||||
return `${block.status === "archived" ? "Archived" : block.status === "deleted" ? "Deleted" : "Bot"} ${block.name ?? ""}`;
|
return `${block.status === "archived" ? "Archived" : block.status === "deleted" ? "Deleted" : "Bot"} ${block.name ?? ""}`;
|
||||||
}
|
}
|
||||||
// Choice cards are answered on the web; here they read as the question and its answer.
|
|
||||||
if (block.kind === "choice") {
|
|
||||||
const answer =
|
|
||||||
block.answerText ?? block.options.find((option) => option.id === block.answerId)?.label;
|
|
||||||
return answer ? `${block.question}\n${answer}` : block.question;
|
|
||||||
}
|
|
||||||
if (block.kind === "chart") return `[chart: ${block.name ?? "chart"}]`;
|
if (block.kind === "chart") return `[chart: ${block.name ?? "chart"}]`;
|
||||||
if (block.kind === "image") return `[image: ${block.name ?? "attachment"}]`;
|
if (block.kind === "image") return `[image: ${block.name ?? "attachment"}]`;
|
||||||
if (block.kind === "file") {
|
if (block.kind === "file") {
|
||||||
return `[file: ${block.name ?? "attachment"}${block.size ? ` (${block.size} bytes)` : ""}]`;
|
return `[file: ${block.name ?? "attachment"}${block.size ? ` (${block.size} bytes)` : ""}]`;
|
||||||
}
|
}
|
||||||
if (block.kind === "video" || block.kind === "audio") {
|
|
||||||
return `[${block.kind}: ${block.name ?? "attachment"}]`;
|
|
||||||
}
|
|
||||||
if (block.kind === "media") return `${block.title}: ${block.status}`;
|
|
||||||
if (block.kind === "steps") {
|
if (block.kind === "steps") {
|
||||||
return (block.steps ?? [])
|
return (block.steps ?? [])
|
||||||
.map((step) => `${step.label}${step.count > 1 ? ` ×${step.count}` : ""}`)
|
.map((step) => `${step.label}${step.count > 1 ? ` ×${step.count}` : ""}`)
|
||||||
@ -926,7 +896,6 @@ type ThreadEvent = {
|
|||||||
type: string;
|
type: string;
|
||||||
seq?: number;
|
seq?: number;
|
||||||
runId?: string;
|
runId?: string;
|
||||||
createdAt?: string;
|
|
||||||
payload?: Record<string, unknown>;
|
payload?: Record<string, unknown>;
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -1105,7 +1074,7 @@ export function applyMobileThreadEvent(
|
|||||||
messages: [...remaining, streaming],
|
messages: [...remaining, streaming],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
if (event.type === "agent.tool.completed" || event.type === "thread.computer") {
|
if (event.type === "agent.tool.completed") {
|
||||||
return { ...prev, cursor: event.seq ?? prev.cursor };
|
return { ...prev, cursor: event.seq ?? prev.cursor };
|
||||||
}
|
}
|
||||||
if (event.type === "thread.subagent") {
|
if (event.type === "thread.subagent") {
|
||||||
|
|||||||
@ -1,7 +1,6 @@
|
|||||||
import { attachmentKindOf } from "@rakazo/contracts";
|
|
||||||
import { File, Paths } from "expo-file-system";
|
import { File, Paths } from "expo-file-system";
|
||||||
import * as Sharing from "expo-sharing";
|
import * as Sharing from "expo-sharing";
|
||||||
import { captureApiRequestContext, rpc } from "./api";
|
import { rpc } from "./api";
|
||||||
import { artifactCacheFileName } from "./artifact-file";
|
import { artifactCacheFileName } from "./artifact-file";
|
||||||
import { t } from "./i18n";
|
import { t } from "./i18n";
|
||||||
|
|
||||||
@ -12,16 +11,6 @@ async function cacheMobileArtifact(
|
|||||||
artifactId: string,
|
artifactId: string,
|
||||||
mimeType: string,
|
mimeType: string,
|
||||||
): Promise<File> {
|
): Promise<File> {
|
||||||
const kind = attachmentKindOf(mimeType);
|
|
||||||
if (kind === "video" || kind === "audio") {
|
|
||||||
// Media can be hundreds of megabytes: stream it to disk instead of a base64 RPC payload.
|
|
||||||
const { apiBase, headers } = await captureApiRequestContext();
|
|
||||||
return File.downloadFileAsync(
|
|
||||||
`${apiBase}/api/artifacts/${encodeURIComponent(artifactId)}`,
|
|
||||||
new File(Paths.cache, artifactCacheFileName(artifactId, mimeType)),
|
|
||||||
{ headers, idempotent: true },
|
|
||||||
);
|
|
||||||
}
|
|
||||||
const artifact = await rpc<{ contentBase64: string }>("artifacts/get", {
|
const artifact = await rpc<{ contentBase64: string }>("artifacts/get", {
|
||||||
...target,
|
...target,
|
||||||
artifactId,
|
artifactId,
|
||||||
|
|||||||
@ -83,7 +83,7 @@ describe("display and warnings", () => {
|
|||||||
expect(apiBaseWarning("http://127.0.0.1:3100")).toBeNull();
|
expect(apiBaseWarning("http://127.0.0.1:3100")).toBeNull();
|
||||||
expect(apiBaseWarning("http://192.168.1.20:3100")).toBeNull();
|
expect(apiBaseWarning("http://192.168.1.20:3100")).toBeNull();
|
||||||
expect(apiBaseWarning("http://100.64.0.1:3100")).toBeNull();
|
expect(apiBaseWarning("http://100.64.0.1:3100")).toBeNull();
|
||||||
expect(apiBaseWarning("http://100.100.100.100:3100")).toBeNull();
|
expect(apiBaseWarning("http://100.119.57.55:3100")).toBeNull();
|
||||||
expect(apiBaseWarning("http://100.127.255.255:3100")).toBeNull();
|
expect(apiBaseWarning("http://100.127.255.255:3100")).toBeNull();
|
||||||
expect(apiBaseWarning("http://app.example.com")).toMatch(/https/i);
|
expect(apiBaseWarning("http://app.example.com")).toMatch(/https/i);
|
||||||
});
|
});
|
||||||
|
|||||||
@ -111,29 +111,6 @@ describe("spaceInboxItems", () => {
|
|||||||
expect(items.map((item) => item.type)).toEqual(["bot", "group"]);
|
expect(items.map((item) => item.type)).toEqual(["bot", "group"]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("lists a project's main conversation instead of its main bot", () => {
|
|
||||||
const main = {
|
|
||||||
id: "group-main",
|
|
||||||
name: "Chief",
|
|
||||||
kind: "main" as const,
|
|
||||||
preview: "",
|
|
||||||
pinned: false,
|
|
||||||
sectionId: null,
|
|
||||||
archivedAt: null,
|
|
||||||
unread: false,
|
|
||||||
updatedAt: "2026-01-01T00:00:00.000Z",
|
|
||||||
members: [],
|
|
||||||
};
|
|
||||||
const lead: MobileBot = { ...bot, id: "bot-lead", name: "Chief", kind: "main" };
|
|
||||||
const items = spaceInboxItems([space({ bots: [lead, bot], groups: [main] })]);
|
|
||||||
expect(items.map((item) => (item.type === "heading" ? item.title : item.type))).toEqual([
|
|
||||||
"Work",
|
|
||||||
"bot",
|
|
||||||
"group",
|
|
||||||
]);
|
|
||||||
expect(items.some((item) => item.type === "bot" && item.bot.id === lead.id)).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("keeps empty spaces beside a populated space", () => {
|
it("keeps empty spaces beside a populated space", () => {
|
||||||
const personal = space({ id: "personal", isDefault: true, bots: [bot] });
|
const personal = space({ id: "personal", isDefault: true, bots: [bot] });
|
||||||
expect(spaceInboxItems([personal, space()]).map((item) => item.type)).toEqual([
|
expect(spaceInboxItems([personal, space()]).map((item) => item.type)).toEqual([
|
||||||
|
|||||||
@ -32,10 +32,7 @@ export function canDeleteInboxSpace(
|
|||||||
export function spaceInboxItems(spaces: InboxSpace[]): InboxSpaceItem[] {
|
export function spaceInboxItems(spaces: InboxSpace[]): InboxSpaceItem[] {
|
||||||
return spaces.flatMap((space): InboxSpaceItem[] => {
|
return spaces.flatMap((space): InboxSpaceItem[] => {
|
||||||
const chats = [
|
const chats = [
|
||||||
// A main bot is reached through its space's main conversation, not listed again.
|
...space.bots.map((chat) => ({ type: "bot" as const, bot: chat, ...chat })),
|
||||||
...space.bots
|
|
||||||
.filter((chat) => chat.kind !== "main")
|
|
||||||
.map((chat) => ({ type: "bot" as const, bot: chat, ...chat })),
|
|
||||||
...space.groups.map((chat) => ({ type: "group" as const, group: chat, ...chat })),
|
...space.groups.map((chat) => ({ type: "group" as const, group: chat, ...chat })),
|
||||||
];
|
];
|
||||||
const items: InboxSpaceItem[] = [];
|
const items: InboxSpaceItem[] = [];
|
||||||
|
|||||||
@ -1,104 +0,0 @@
|
|||||||
import { expect, test } from "@playwright/test";
|
|
||||||
import { activeBotId, captureScreenshot, completeOnboarding, rpc, signup } from "./helpers";
|
|
||||||
|
|
||||||
const SCREEN_URL = "https://screen.example/embed.html";
|
|
||||||
|
|
||||||
test("the bot's screen opens beside the chat while it works and steps back after", async ({
|
|
||||||
page,
|
|
||||||
}, testInfo) => {
|
|
||||||
await signup(page, `workspace-screen-${Date.now()}@rakazo.test`, "password12", "Screen Pane");
|
|
||||||
await completeOnboarding(page);
|
|
||||||
const botId = activeBotId(page);
|
|
||||||
await rpc(page, "computer/boot", { botId });
|
|
||||||
await page.route("https://screen.example/**", (route) =>
|
|
||||||
route.fulfill({
|
|
||||||
contentType: "text/html",
|
|
||||||
body: `<!doctype html><title>Desktop</title><body style="margin:0;background:#1d1d20;display:grid;place-items:center;height:100vh">
|
|
||||||
<input style="width:40%;padding:8px;font:16px system-ui" placeholder="Search">`,
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
await page.route("**/rpc/computer/screenUrl", (route) =>
|
|
||||||
route.fulfill({
|
|
||||||
contentType: "application/json",
|
|
||||||
body: JSON.stringify({ json: { url: SCREEN_URL } }),
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
const sidePanel = page.getByTestId("side-panel");
|
|
||||||
await expect(sidePanel).toHaveAttribute("data-panel", "closed");
|
|
||||||
|
|
||||||
await rpc(page, "threads/send", { botId, text: "use your screen and type workspace-demo" });
|
|
||||||
|
|
||||||
await expect(sidePanel).toHaveAttribute("data-panel", "computer");
|
|
||||||
await expect(sidePanel).toHaveAttribute("data-auto", "");
|
|
||||||
await expect(page.getByTestId("computer-preview").locator("iframe")).toHaveAttribute(
|
|
||||||
"src",
|
|
||||||
SCREEN_URL,
|
|
||||||
);
|
|
||||||
// The live screen gets more room than a settings panel.
|
|
||||||
expect((await sidePanel.boundingBox())!.width).toBeGreaterThan(400);
|
|
||||||
await captureScreenshot(page, testInfo, "workspace-screen-beside-chat");
|
|
||||||
|
|
||||||
// Once the work is done the conversation gets its full width back.
|
|
||||||
await expect(sidePanel).toHaveAttribute("data-panel", "closed", { timeout: 20_000 });
|
|
||||||
});
|
|
||||||
|
|
||||||
test("a report the bot presents opens beside the conversation", async ({ page }, testInfo) => {
|
|
||||||
await signup(page, `workspace-report-${Date.now()}@rakazo.test`, "password12", "Report Pane");
|
|
||||||
await completeOnboarding(page);
|
|
||||||
|
|
||||||
const composer = page.getByPlaceholder(/Message/);
|
|
||||||
await composer.fill("present the report");
|
|
||||||
await page.keyboard.press("Enter");
|
|
||||||
|
|
||||||
const pane = page.getByTestId("artifact-pane");
|
|
||||||
await expect(pane).toBeVisible({ timeout: 30_000 });
|
|
||||||
await expect(pane.getByRole("heading", { name: "report.md" })).toBeVisible();
|
|
||||||
await expect(pane.getByRole("heading", { name: "Report", exact: true })).toBeVisible();
|
|
||||||
await expect(pane.getByRole("cell", { name: "Research" })).toBeVisible();
|
|
||||||
await expect(composer).toBeVisible();
|
|
||||||
await captureScreenshot(page, testInfo, "workspace-report-beside-chat");
|
|
||||||
|
|
||||||
const downloadPromise = page.waitForEvent("download");
|
|
||||||
await pane.getByRole("button", { name: "Download report.md" }).click();
|
|
||||||
expect((await downloadPromise).suggestedFilename()).toBe("report.md");
|
|
||||||
|
|
||||||
await pane.getByRole("button", { name: "Close panel" }).click();
|
|
||||||
await expect(pane).toHaveCount(0);
|
|
||||||
|
|
||||||
// The file card in the chat opens the same pane again.
|
|
||||||
await page.getByRole("button", { name: "Preview report.md" }).click();
|
|
||||||
await expect(pane.getByRole("heading", { name: "Report", exact: true })).toBeVisible();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("a question that scrolled away is one click back", async ({ page }, testInfo) => {
|
|
||||||
await page.setViewportSize({ width: 1280, height: 520 });
|
|
||||||
await signup(page, `workspace-ask-${Date.now()}@rakazo.test`, "password12", "Ask Jump");
|
|
||||||
await completeOnboarding(page);
|
|
||||||
|
|
||||||
const composer = page.getByPlaceholder(/Message/);
|
|
||||||
// Earlier work gives the thread enough history to scroll back through.
|
|
||||||
for (const task of ["sort the inbox", "draft the agenda", "book the room"]) {
|
|
||||||
await composer.fill(task);
|
|
||||||
await page.keyboard.press("Enter");
|
|
||||||
await expect(page.getByText(`done. i handled: ${task}`, { exact: true })).toBeVisible({
|
|
||||||
timeout: 30_000,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
await composer.fill("ask me which city to use");
|
|
||||||
await page.keyboard.press("Enter");
|
|
||||||
const sendIt = page.getByRole("button", { name: "Send it" });
|
|
||||||
await expect(sendIt).toBeVisible({ timeout: 30_000 });
|
|
||||||
|
|
||||||
const jump = page.getByTestId("needs-you-jump");
|
|
||||||
await expect(jump).toHaveCount(0);
|
|
||||||
await page.getByTestId("transcript").hover();
|
|
||||||
await page.mouse.wheel(0, -20_000);
|
|
||||||
await expect(jump).toBeVisible();
|
|
||||||
await captureScreenshot(page, testInfo, "workspace-needs-you-jump");
|
|
||||||
|
|
||||||
await jump.click();
|
|
||||||
await expect(sendIt).toBeInViewport();
|
|
||||||
await expect(sendIt).toBeFocused();
|
|
||||||
await expect(jump).toHaveCount(0);
|
|
||||||
});
|
|
||||||
@ -1,53 +0,0 @@
|
|||||||
import { expect, test } from "@playwright/test";
|
|
||||||
import { activeBotId, captureScreenshot, completeOnboarding, rpc, signup } from "./helpers";
|
|
||||||
|
|
||||||
const SCREEN_URL = "https://screen.example/embed.html";
|
|
||||||
|
|
||||||
test("the bot's cursor glides to where it clicks on its live screen", async ({
|
|
||||||
page,
|
|
||||||
}, testInfo) => {
|
|
||||||
await signup(page, `agent-cursor-${Date.now()}@rakazo.test`, "password12", "Cursor Watcher");
|
|
||||||
await completeOnboarding(page);
|
|
||||||
const botId = activeBotId(page);
|
|
||||||
await rpc(page, "computer/boot", { botId });
|
|
||||||
// Stand in for the noVNC viewer: a 16:10 desktop with a field in the middle.
|
|
||||||
await page.route("https://screen.example/**", (route) =>
|
|
||||||
route.fulfill({
|
|
||||||
contentType: "text/html",
|
|
||||||
body: `<!doctype html><title>Desktop</title><body style="margin:0;background:#1d1d20;display:grid;place-items:center;height:100vh">
|
|
||||||
<input style="width:40%;padding:8px;font:16px system-ui" placeholder="Search">`,
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
await page.route("**/rpc/computer/screenUrl", (route) =>
|
|
||||||
route.fulfill({
|
|
||||||
contentType: "application/json",
|
|
||||||
body: JSON.stringify({ json: { url: SCREEN_URL } }),
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
await page.getByTitle("Agent computer").click();
|
|
||||||
const preview = page.getByTestId("computer-preview");
|
|
||||||
await expect(preview.locator("iframe")).toHaveAttribute("src", SCREEN_URL);
|
|
||||||
const cursor = preview.getByTestId("agent-cursor");
|
|
||||||
await expect(cursor).toHaveCSS("opacity", "0");
|
|
||||||
|
|
||||||
// The scripted runtime clicks the screen center (640, 400) and types.
|
|
||||||
await rpc(page, "threads/send", { botId, text: "use your screen and type cursor-demo" });
|
|
||||||
|
|
||||||
await expect(cursor).toHaveCSS("opacity", "1");
|
|
||||||
await expect(cursor).toHaveAttribute("data-action", /click|type/);
|
|
||||||
const layer = (await preview.getByTestId("agent-cursor-layer").boundingBox())!;
|
|
||||||
await expect
|
|
||||||
.poll(async () => {
|
|
||||||
const tip = (await cursor.boundingBox())!;
|
|
||||||
return [
|
|
||||||
Math.round(tip.x - layer.x - layer.width / 2),
|
|
||||||
Math.round(tip.y - layer.y - layer.height / 2),
|
|
||||||
];
|
|
||||||
})
|
|
||||||
.toEqual([0, 0]);
|
|
||||||
await captureScreenshot(page, testInfo, "agent-cursor-on-screen");
|
|
||||||
|
|
||||||
// It fades once the bot stops pointing, so it never lingers over the screen.
|
|
||||||
await expect(cursor).toHaveCSS("opacity", "0", { timeout: 10_000 });
|
|
||||||
});
|
|
||||||
@ -27,8 +27,6 @@ test("agent-attached files appear as downloadable cards", async ({ page }, testI
|
|||||||
test("agent-attached Markdown opens a rendered preview and can be downloaded", async ({
|
test("agent-attached Markdown opens a rendered preview and can be downloaded", async ({
|
||||||
page,
|
page,
|
||||||
}, testInfo) => {
|
}, testInfo) => {
|
||||||
// Narrow windows preview in a dialog; wide ones open the file beside the chat.
|
|
||||||
await page.setViewportSize({ width: 900, height: 800 });
|
|
||||||
const stamp = Date.now();
|
const stamp = Date.now();
|
||||||
await signup(page, `markdown-preview-${stamp}@rakazo.test`, "password12", "Markdown Preview");
|
await signup(page, `markdown-preview-${stamp}@rakazo.test`, "password12", "Markdown Preview");
|
||||||
await completeOnboarding(page);
|
await completeOnboarding(page);
|
||||||
|
|||||||
@ -179,66 +179,3 @@ test("bot creation, editing, and deletion persist", async ({ page }, testInfo) =
|
|||||||
await expect(page.getByRole("combobox", { name: "Message" })).toBeVisible();
|
await expect(page.getByRole("combobox", { name: "Message" })).toBeVisible();
|
||||||
await captureScreenshot(page, testInfo, "31-deleted-bot-fallback");
|
await captureScreenshot(page, testInfo, "31-deleted-bot-fallback");
|
||||||
});
|
});
|
||||||
|
|
||||||
test("a bot export restores as a new bot", async ({ page }, testInfo) => {
|
|
||||||
const stamp = Date.now();
|
|
||||||
await signup(page, `bot-import-${stamp}@rakazo.test`, "password12", "Bot Import");
|
|
||||||
await completeOnboarding(page);
|
|
||||||
await page.goto("/app");
|
|
||||||
await page.waitForURL(/\/app\/[^/]+$/);
|
|
||||||
|
|
||||||
await openNewBot(page);
|
|
||||||
const form = page.getByTestId("create-bot-form");
|
|
||||||
await expect(form.getByRole("button", { name: "Import bot" })).toBeVisible();
|
|
||||||
for (const colorScheme of ["light", "dark"] as const) {
|
|
||||||
await page.emulateMedia({ colorScheme });
|
|
||||||
await expect(page.locator("html")).toHaveAttribute("data-theme", colorScheme);
|
|
||||||
await captureScreenshot(page, testInfo, `27d-create-bot-import-${colorScheme}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const fileInput = form.getByTestId("import-bot-file");
|
|
||||||
await fileInput.setInputFiles({
|
|
||||||
name: "broken.json",
|
|
||||||
mimeType: "application/json",
|
|
||||||
buffer: Buffer.from("{"),
|
|
||||||
});
|
|
||||||
await expect(page.getByTestId("create-bot-error")).toHaveText("Could not import this file");
|
|
||||||
|
|
||||||
await fileInput.setInputFiles({
|
|
||||||
name: "archivist-export.json",
|
|
||||||
mimeType: "application/json",
|
|
||||||
buffer: Buffer.from(
|
|
||||||
JSON.stringify({
|
|
||||||
version: 1,
|
|
||||||
exportedAt: new Date().toISOString(),
|
|
||||||
bot: {
|
|
||||||
name: "Archivist",
|
|
||||||
title: "Keeps the records",
|
|
||||||
description: "Files and finds documents.",
|
|
||||||
instructions: "Name every file by date.",
|
|
||||||
},
|
|
||||||
memory: [{ path: "MEMORY.md", content: "# Archive\n\nPrefers ISO dates." }],
|
|
||||||
routines: [
|
|
||||||
{
|
|
||||||
name: "Weekly sweep",
|
|
||||||
prompt: "Tidy the archive",
|
|
||||||
crons: ["0 8 * * 1"],
|
|
||||||
timezone: "UTC",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
files: [{ path: "notes/index.md", content: "imported" }],
|
|
||||||
history: [],
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
});
|
|
||||||
await expect(page.getByTestId("side-panel")).toHaveAttribute("data-panel", "closed");
|
|
||||||
const botList = page.locator("aside").first();
|
|
||||||
await expect(botList.getByRole("button", { name: /^Archivist/ })).toBeVisible();
|
|
||||||
await page.locator("main").getByRole("button", { name: "Archivist", exact: true }).click();
|
|
||||||
await expect(page.locator("label:has-text('Title') input")).toHaveValue("Keeps the records");
|
|
||||||
for (const colorScheme of ["light", "dark"] as const) {
|
|
||||||
await page.emulateMedia({ colorScheme });
|
|
||||||
await expect(page.locator("html")).toHaveAttribute("data-theme", colorScheme);
|
|
||||||
await captureScreenshot(page, testInfo, `27e-imported-bot-${colorScheme}`);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|||||||
@ -4,10 +4,6 @@ import { captureScreenshot } from "./helpers";
|
|||||||
const fixture = "/e2e/fixtures/chart-links.html";
|
const fixture = "/e2e/fixtures/chart-links.html";
|
||||||
|
|
||||||
test("chart links cannot execute script when clicked or expanded", async ({ page }, testInfo) => {
|
test("chart links cannot execute script when clicked or expanded", async ({ page }, testInfo) => {
|
||||||
test.setTimeout(120_000);
|
|
||||||
// ChartCanvas lazy-loads Observable Plot; pay the import cost once before the matrix below.
|
|
||||||
await page.goto(`${fixture}?${new URLSearchParams({ href: "#details", inspect: "0" })}`);
|
|
||||||
await expect(page.locator("svg circle").first()).toBeVisible({ timeout: 90_000 });
|
|
||||||
for (const inspect of ["0", "1"]) {
|
for (const inspect of ["0", "1"]) {
|
||||||
for (const href of [
|
for (const href of [
|
||||||
'javascript:void(document.body.dataset.plotExecuted="yes")',
|
'javascript:void(document.body.dataset.plotExecuted="yes")',
|
||||||
|
|||||||
@ -224,6 +224,7 @@ test("shell surfaces stay readable in light and dark themes", async ({ page }, t
|
|||||||
const sidePanel = page.getByTestId("side-panel");
|
const sidePanel = page.getByTestId("side-panel");
|
||||||
await expect(sidebar.locator("[data-main-conversation]")).toBeVisible();
|
await expect(sidebar.locator("[data-main-conversation]")).toBeVisible();
|
||||||
await expect(sidebar.getByRole("button", { name: "Researcher, 1:1 conversation" })).toBeVisible();
|
await expect(sidebar.getByRole("button", { name: "Researcher, 1:1 conversation" })).toBeVisible();
|
||||||
|
await expect(page.getByTestId("trading-mode-status")).toBeVisible();
|
||||||
|
|
||||||
await inEachTheme(page, async (theme) => {
|
await inEachTheme(page, async (theme) => {
|
||||||
await captureScreenshot(page, testInfo, `shell-contrast-${theme}`);
|
await captureScreenshot(page, testInfo, `shell-contrast-${theme}`);
|
||||||
@ -253,9 +254,9 @@ test("shell surfaces stay readable in light and dark themes", async ({ page }, t
|
|||||||
await expectReadable(sidePanel, `${theme}: the bot settings form`, { inside: true });
|
await expectReadable(sidePanel, `${theme}: the bot settings form`, { inside: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
await sidebar.getByRole("button", { name: "Today", exact: true }).click();
|
await sidebar.getByRole("button", { name: "Heute", exact: true }).click();
|
||||||
const heute = page.getByTestId("heute-brief");
|
const heute = page.getByTestId("heute-brief");
|
||||||
await expect(heute).toBeVisible();
|
await expect(heute.getByTestId("heute-mission-control-link")).toBeVisible();
|
||||||
await inEachTheme(page, async (theme) => {
|
await inEachTheme(page, async (theme) => {
|
||||||
await captureScreenshot(page, testInfo, `shell-contrast-${theme}-heute`);
|
await captureScreenshot(page, testInfo, `shell-contrast-${theme}-heute`);
|
||||||
await expectReadable(heute, `${theme}: the Heute brief`, { inside: true });
|
await expectReadable(heute, `${theme}: the Heute brief`, { inside: true });
|
||||||
|
|||||||
98
apps/web/e2e/empty-project-starten.spec.ts
Normal file
98
apps/web/e2e/empty-project-starten.spec.ts
Normal file
@ -0,0 +1,98 @@
|
|||||||
|
import { expect, type Locator, test } from "@playwright/test";
|
||||||
|
import { completeOnboarding, rpc, signup, unterhaltungenOnly } from "./helpers";
|
||||||
|
|
||||||
|
async function selectProjectSpace(projekte: Locator, name: string) {
|
||||||
|
const row = projekte.locator('[data-sidebar-group^="project-space:"]').filter({ hasText: name });
|
||||||
|
await row.getByRole("button", { name: `Select ${name}` }).click();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function projectSidebarStart(projekte: Locator, name: string) {
|
||||||
|
const row = projekte.locator('[data-sidebar-group^="project-space:"]').filter({ hasText: name });
|
||||||
|
const groupAttr = await row.getAttribute("data-sidebar-group");
|
||||||
|
const spaceId = groupAttr?.match(/^project-space:([^:]+)/)?.[1];
|
||||||
|
if (!spaceId) throw new Error(`missing project space id for ${name}`);
|
||||||
|
return projekte.getByTestId(`project-start-${spaceId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
test("empty project shows Starten instead of a fallback bot", async ({ page }) => {
|
||||||
|
const stamp = Date.now();
|
||||||
|
await signup(page, `empty-project-${stamp}@rakazo.test`, "password12", "Empty Project");
|
||||||
|
await completeOnboarding(page);
|
||||||
|
await page.goto("/app");
|
||||||
|
await page.waitForURL(/\/app\/[^/]+$/);
|
||||||
|
|
||||||
|
const projekte = page.locator(".sidebar-projekte");
|
||||||
|
await projekte.getByTestId("create-project-trigger").click();
|
||||||
|
const dialog = page.getByRole("dialog", { name: "New project" });
|
||||||
|
await dialog.getByLabel("Name").fill("Launch");
|
||||||
|
await dialog.getByRole("button", { name: "Create project", exact: true }).click();
|
||||||
|
await page.waitForURL(/\/app(?:\/)?$/);
|
||||||
|
|
||||||
|
await expect(projekte.getByRole("button", { name: /^Chief/ })).toHaveCount(0);
|
||||||
|
await expect(projekte.getByRole("button", { name: "Starten", exact: true })).toBeVisible();
|
||||||
|
await expect(page.getByTestId("project-start-main")).toBeVisible();
|
||||||
|
|
||||||
|
await page.getByTestId("project-start-main").click();
|
||||||
|
await expect(page.getByTestId("side-panel")).toHaveAttribute("data-panel", "create");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Starten in an onboarded project opens the group create flow", async ({ page }) => {
|
||||||
|
const stamp = Date.now();
|
||||||
|
await signup(page, `project-start-${stamp}@rakazo.test`, "password12", "Project Start");
|
||||||
|
await completeOnboarding(page);
|
||||||
|
|
||||||
|
const projekte = page.locator(".sidebar-projekte");
|
||||||
|
await projekte.getByTestId("create-project-trigger").click();
|
||||||
|
const dialog = page.getByRole("dialog", { name: "New project" });
|
||||||
|
await dialog.getByLabel("Name").fill("Website");
|
||||||
|
await dialog.getByRole("button", { name: "Create project", exact: true }).click();
|
||||||
|
await page.waitForURL(/\/app(?:\/)?$/);
|
||||||
|
await selectProjectSpace(projekte, "Website");
|
||||||
|
await page.waitForURL(/\/app(?:\/)?$/);
|
||||||
|
|
||||||
|
await expect(page.getByTestId("shell-root")).toHaveAttribute("data-ready", "true", {
|
||||||
|
timeout: 60_000,
|
||||||
|
});
|
||||||
|
await selectProjectSpace(projekte, "Website");
|
||||||
|
await page.waitForURL(/\/app(?:\/)?$/);
|
||||||
|
await expect(unterhaltungenOnly(page).getByRole("button", { name: /^Chief/ })).toHaveCount(1);
|
||||||
|
await expect(projekte.getByRole("button", { name: /^Chief/ })).toHaveCount(0);
|
||||||
|
const sidebarStart = await projectSidebarStart(projekte, "Website");
|
||||||
|
await expect(sidebarStart).toBeVisible({ timeout: 30_000 });
|
||||||
|
|
||||||
|
await sidebarStart.click();
|
||||||
|
await expect(page.getByTestId("side-panel")).toHaveAttribute("data-panel", "create-group", {
|
||||||
|
timeout: 30_000,
|
||||||
|
});
|
||||||
|
await expect(page.getByText("New group", { exact: true })).toBeVisible();
|
||||||
|
await expect(
|
||||||
|
page.getByTestId("side-panel").getByRole("button", { name: /^Chief/ }),
|
||||||
|
).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("archiving the only bot keeps Starten visible in the project sidebar", async ({ page }) => {
|
||||||
|
const stamp = Date.now();
|
||||||
|
await signup(page, `project-archive-${stamp}@rakazo.test`, "password12", "Project Archive");
|
||||||
|
await completeOnboarding(page);
|
||||||
|
|
||||||
|
const projekte = page.locator(".sidebar-projekte");
|
||||||
|
await projekte.getByTestId("create-project-trigger").click();
|
||||||
|
const dialog = page.getByRole("dialog", { name: "New project" });
|
||||||
|
await dialog.getByLabel("Name").fill("Side");
|
||||||
|
await dialog.getByRole("button", { name: "Create project", exact: true }).click();
|
||||||
|
await page.waitForURL(/\/app(?:\/)?$/);
|
||||||
|
|
||||||
|
const sideBot = await rpc<{ id: string }>(page, "bots/create", {
|
||||||
|
name: "Side bot",
|
||||||
|
title: "",
|
||||||
|
description: "",
|
||||||
|
notifyOnFinish: true,
|
||||||
|
computerMode: "team",
|
||||||
|
});
|
||||||
|
await rpc(page, "bots/archive", { botId: sideBot.id });
|
||||||
|
|
||||||
|
await page.goto("/app");
|
||||||
|
await page.waitForURL(/\/app(?:\/)?$/);
|
||||||
|
await expect(projekte.getByRole("button", { name: "Starten", exact: true })).toBeVisible();
|
||||||
|
await expect(page.getByTestId("project-start-main")).toBeVisible();
|
||||||
|
});
|
||||||
Binary file not shown.
@ -69,10 +69,9 @@ test("takeover, routine, plugins, and export are reachable", async ({ page }, te
|
|||||||
})
|
})
|
||||||
.toBe("waiting_takeover");
|
.toBe("waiting_takeover");
|
||||||
await captureScreenshot(page, testInfo, "08-protected-input-request");
|
await captureScreenshot(page, testInfo, "08-protected-input-request");
|
||||||
// A takeover request brings the bot's screen beside the chat on its own.
|
await page.getByTitle("Agent computer").click();
|
||||||
const sidePanel = page.getByTestId("side-panel");
|
const sidePanel = page.getByTestId("side-panel");
|
||||||
await expect(sidePanel).toHaveAttribute("data-panel", "computer");
|
await expect(sidePanel).toHaveCSS("width", "384px");
|
||||||
await expect(sidePanel).toHaveAttribute("data-auto", "");
|
|
||||||
const [mainBox, panelBox] = await Promise.all([
|
const [mainBox, panelBox] = await Promise.all([
|
||||||
page.locator("main").boundingBox(),
|
page.locator("main").boundingBox(),
|
||||||
sidePanel.boundingBox(),
|
sidePanel.boundingBox(),
|
||||||
|
|||||||
@ -303,11 +303,9 @@ test("create group from + and see two bots in one transcript", async ({ page },
|
|||||||
const groupMarkdown = page.getByRole("button", { name: "Preview group-preview.md" });
|
const groupMarkdown = page.getByRole("button", { name: "Preview group-preview.md" });
|
||||||
await expect(groupMarkdown).toBeVisible({ timeout: 30_000 });
|
await expect(groupMarkdown).toBeVisible({ timeout: 30_000 });
|
||||||
await groupMarkdown.click();
|
await groupMarkdown.click();
|
||||||
// Wide windows open the file beside the group conversation.
|
const markdownDialog = page.getByRole("dialog", { name: "group-preview.md" });
|
||||||
const markdownPane = page.getByTestId("artifact-pane");
|
await expect(markdownDialog.getByRole("heading", { name: "Group artifact" })).toBeVisible();
|
||||||
await expect(markdownPane.getByRole("heading", { name: "Group artifact" })).toBeVisible();
|
await markdownDialog.getByRole("button", { name: "Close preview" }).click();
|
||||||
await markdownPane.getByRole("button", { name: "Close panel" }).click();
|
|
||||||
await expect(markdownPane).toHaveCount(0);
|
|
||||||
|
|
||||||
await page.setViewportSize({ width: 390, height: 844 });
|
await page.setViewportSize({ width: 390, height: 844 });
|
||||||
await expect(page.getByRole("button", { name: "Open navigation" })).toBeVisible();
|
await expect(page.getByRole("button", { name: "Open navigation" })).toBeVisible();
|
||||||
|
|||||||
@ -88,9 +88,8 @@ async function visible(locator: Locator, timeout: number) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Walks the first-run wizard, which lands in the main conversation with the main bot's first
|
* Walks first-run onboarding, which lands in the main conversation. Unless `mainConversation`
|
||||||
* onboarding question pending (the composer works regardless). Unless `mainConversation` is
|
* is set, it then opens the main bot's own thread, where most specs start.
|
||||||
* set, it then opens the main bot's own thread, where most specs start.
|
|
||||||
*/
|
*/
|
||||||
export async function completeOnboarding(
|
export async function completeOnboarding(
|
||||||
page: Page,
|
page: Page,
|
||||||
@ -102,12 +101,13 @@ export async function completeOnboarding(
|
|||||||
if (page.url().includes("/onboarding")) {
|
if (page.url().includes("/onboarding")) {
|
||||||
await expect(page.locator('[data-rakazo-surface="onboarding"]')).toHaveAttribute(
|
await expect(page.locator('[data-rakazo-surface="onboarding"]')).toHaveAttribute(
|
||||||
"data-onboarding-step",
|
"data-onboarding-step",
|
||||||
/^(emoji|language|voice|integrations|scan|bot)$/,
|
/^(emoji|preferences|language|voice|integrations|scan|bot)$/,
|
||||||
{ timeout: 30_000 },
|
{ timeout: 30_000 },
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const design = page.getByRole("heading", { name: /Design your Oo/i });
|
const design = page.getByRole("heading", { name: /Design your Oo/i });
|
||||||
|
const preferences = page.getByRole("heading", { name: /Tell us about yourself/i });
|
||||||
const language = page.getByRole("heading", { name: /Choose your language/i });
|
const language = page.getByRole("heading", { name: /Choose your language/i });
|
||||||
const voice = page.getByRole("heading", { name: /Pick a voice/i });
|
const voice = page.getByRole("heading", { name: /Pick a voice/i });
|
||||||
const integrations = page.getByRole("heading", { name: "Server integrations", exact: true });
|
const integrations = page.getByRole("heading", { name: "Server integrations", exact: true });
|
||||||
@ -119,6 +119,8 @@ export async function completeOnboarding(
|
|||||||
await expect(entry.first()).toBeVisible({ timeout: 20_000 });
|
await expect(entry.first()).toBeVisible({ timeout: 20_000 });
|
||||||
|
|
||||||
if (await design.isVisible()) {
|
if (await design.isVisible()) {
|
||||||
|
await page.getByRole("button", { name: "Continue", exact: true }).click();
|
||||||
|
await expect(preferences).toBeVisible();
|
||||||
await page.getByRole("button", { name: "Continue", exact: true }).click();
|
await page.getByRole("button", { name: "Continue", exact: true }).click();
|
||||||
await expect(language).toBeVisible();
|
await expect(language).toBeVisible();
|
||||||
await page.getByRole("button", { name: /Skip/i }).click();
|
await page.getByRole("button", { name: /Skip/i }).click();
|
||||||
@ -131,15 +133,8 @@ export async function completeOnboarding(
|
|||||||
await page.getByRole("button", { name: "Skip", exact: true }).click();
|
await page.getByRole("button", { name: "Skip", exact: true }).click();
|
||||||
}
|
}
|
||||||
|
|
||||||
// The scan step, when shown, either asks first (skip it) or advances by itself. Onboarding
|
// Scan step auto-advances to the main bot, then onboarding lands in the main conversation.
|
||||||
// then lands in the main conversation.
|
await page.waitForURL(/\/app\/g\/[^/]+$/, { timeout: 30_000 });
|
||||||
const skipScan = page
|
|
||||||
.locator('[data-onboarding-step="scan"]')
|
|
||||||
.getByRole("button", { name: "Skip", exact: true });
|
|
||||||
await expect(async () => {
|
|
||||||
if (await skipScan.isVisible()) await skipScan.click();
|
|
||||||
expect(page.url()).toMatch(/\/app\/g\/[^/]+$/);
|
|
||||||
}).toPass({ timeout: 30_000 });
|
|
||||||
await expect(chief).toBeVisible({ timeout: 15_000 });
|
await expect(chief).toBeVisible({ timeout: 15_000 });
|
||||||
await waitForChatComposer(page);
|
await waitForChatComposer(page);
|
||||||
|
|
||||||
@ -247,7 +242,6 @@ export async function openUserSettings(
|
|||||||
| "memory"
|
| "memory"
|
||||||
| "profile"
|
| "profile"
|
||||||
| "voice"
|
| "voice"
|
||||||
| "notifications"
|
|
||||||
| "usage"
|
| "usage"
|
||||||
| "computer"
|
| "computer"
|
||||||
| "updates",
|
| "updates",
|
||||||
|
|||||||
@ -1,136 +1,20 @@
|
|||||||
import type { Page } from "@playwright/test";
|
|
||||||
import { expect, test } from "@playwright/test";
|
import { expect, test } from "@playwright/test";
|
||||||
import type { CoachRhythm, CoachToday, Goal, Habit, Routine } from "@rakazo/contracts";
|
import { captureScreenshot, completeOnboarding, signup } from "./helpers";
|
||||||
import { captureScreenshot, completeOnboarding, openMainBotThread, rpc, signup } from "./helpers";
|
|
||||||
|
|
||||||
/** The nav button reads "Today" or "Today, N need you". */
|
|
||||||
function openHeute(page: Page) {
|
|
||||||
return page.getByRole("button", { name: /^Today(,|$)/ }).click();
|
|
||||||
}
|
|
||||||
|
|
||||||
function shiftDate(date: string, days: number) {
|
|
||||||
return new Date(Date.parse(`${date}T00:00:00Z`) + days * 86_400_000).toISOString().slice(0, 10);
|
|
||||||
}
|
|
||||||
|
|
||||||
test("Heute shows native brief instead of Mission Control iframe", async ({ page }, testInfo) => {
|
test("Heute shows native brief instead of Mission Control iframe", async ({ page }, testInfo) => {
|
||||||
const stamp = Date.now();
|
const stamp = Date.now();
|
||||||
await signup(page, `heute-${stamp}@rakazo.test`, "password12", "Heute");
|
await signup(page, `heute-${stamp}@rakazo.test`, "password12", "Heute");
|
||||||
await completeOnboarding(page);
|
await completeOnboarding(page);
|
||||||
|
|
||||||
await openHeute(page);
|
await page.getByRole("button", { name: "Heute", exact: true }).click();
|
||||||
await expect(page).toHaveURL(/\/app\/[^/]+$/);
|
await expect(page).toHaveURL(/\/app\/[^/]+$/);
|
||||||
|
|
||||||
const brief = page.getByTestId("heute-brief");
|
const brief = page.getByTestId("heute-brief");
|
||||||
await expect(brief).toBeVisible();
|
await expect(brief).toBeVisible();
|
||||||
await expect(page.locator('iframe[src="http://127.0.0.1:3088/"]')).toHaveCount(0);
|
await expect(page.locator('iframe[src="http://127.0.0.1:3088/"]')).toHaveCount(0);
|
||||||
await expect(page.getByText("evOo Mission Control")).toHaveCount(0);
|
await expect(page.getByText("evOo Mission Control")).toHaveCount(0);
|
||||||
await expect(page.getByTestId("heute-mission-control-link")).toHaveCount(0);
|
|
||||||
await expect(page.getByTestId("heute-status")).toBeVisible();
|
await expect(page.getByTestId("heute-status")).toBeVisible();
|
||||||
// Nothing tracked yet: no coach sections.
|
await expect(page.getByTestId("heute-mission-control-link")).toBeVisible();
|
||||||
await expect(page.getByTestId("heute-coach-brief")).toHaveCount(0);
|
|
||||||
await expect(page.getByTestId("heute-habits")).toHaveCount(0);
|
|
||||||
|
|
||||||
await captureScreenshot(page, testInfo, "heute-native-brief");
|
await captureScreenshot(page, testInfo, "heute-native-brief");
|
||||||
});
|
});
|
||||||
|
|
||||||
test("Heute shows the coach's brief, habits and goals, checks habits off and sets check-ins", async ({
|
|
||||||
page,
|
|
||||||
}, testInfo) => {
|
|
||||||
const stamp = Date.now();
|
|
||||||
await signup(page, `heute-coach-${stamp}@rakazo.test`, "password12", "Heute Coach");
|
|
||||||
await completeOnboarding(page);
|
|
||||||
|
|
||||||
// The main bot writes today's brief through its tool.
|
|
||||||
const composer = page.getByRole("combobox", { name: "Message" });
|
|
||||||
await composer.fill("write today's brief: Two calm hours on the beta, then a walk.");
|
|
||||||
await page.keyboard.press("Enter");
|
|
||||||
await expect(page.getByText("today's brief is ready.").first()).toBeVisible({ timeout: 30_000 });
|
|
||||||
|
|
||||||
const { date } = await rpc<CoachToday>(page, "coach/today", {});
|
|
||||||
const goal = await rpc<Goal>(page, "coach/goals/create", {
|
|
||||||
title: "Ship the beta",
|
|
||||||
targetDate: shiftDate(date, 30),
|
|
||||||
});
|
|
||||||
const habit = await rpc<Habit>(page, "coach/habits/create", { title: "Walk 20 minutes" });
|
|
||||||
for (const day of [1, 2]) {
|
|
||||||
await rpc(page, "coach/checkIns/log", {
|
|
||||||
kind: "habit",
|
|
||||||
habitId: habit.id,
|
|
||||||
date: shiftDate(date, -day),
|
|
||||||
done: true,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
await openHeute(page);
|
|
||||||
await expect(page.getByTestId("heute-coach-brief")).toHaveText(
|
|
||||||
"Two calm hours on the beta, then a walk.",
|
|
||||||
);
|
|
||||||
await expect(page.getByTestId(`heute-goal-${goal.id}`)).toContainText("Ship the beta");
|
|
||||||
const habitRow = page.getByTestId(`heute-habit-${habit.id}`);
|
|
||||||
await expect(habitRow).toContainText("Walk 20 minutes");
|
|
||||||
await expect(page.getByTestId(`heute-habit-streak-${habit.id}`)).toHaveText("2 days");
|
|
||||||
|
|
||||||
const checkbox = habitRow.getByRole("checkbox");
|
|
||||||
await expect(checkbox).not.toBeChecked();
|
|
||||||
const logged = page.waitForResponse(
|
|
||||||
(response) => response.url().includes("/rpc/coach/checkIns/log") && response.ok(),
|
|
||||||
);
|
|
||||||
await checkbox.click();
|
|
||||||
await logged;
|
|
||||||
await expect(checkbox).toBeChecked();
|
|
||||||
await expect(page.getByTestId(`heute-habit-streak-${habit.id}`)).toHaveText("3 days");
|
|
||||||
await captureScreenshot(page, testInfo, "heute-coach");
|
|
||||||
|
|
||||||
await page.reload();
|
|
||||||
await openHeute(page);
|
|
||||||
await expect(page.getByTestId(`heute-habit-${habit.id}`).getByRole("checkbox")).toBeChecked();
|
|
||||||
|
|
||||||
// Check-in times live behind one compact control.
|
|
||||||
await page.getByTestId("heute-rhythm").click();
|
|
||||||
const panel = page.getByTestId("heute-rhythm-panel");
|
|
||||||
await expect(panel).toBeVisible();
|
|
||||||
const morning = panel.getByTestId("heute-rhythm-morning");
|
|
||||||
await expect(morning.getByRole("switch")).not.toBeChecked();
|
|
||||||
const enabled = page.waitForResponse(
|
|
||||||
(response) => response.url().includes("/rpc/coach/rhythm/set") && response.ok(),
|
|
||||||
);
|
|
||||||
await morning.getByRole("switch").click();
|
|
||||||
await enabled;
|
|
||||||
const time = morning.locator('input[type="time"]');
|
|
||||||
await expect(time).toHaveValue("08:00");
|
|
||||||
const moved = page.waitForResponse(
|
|
||||||
(response) => response.url().includes("/rpc/coach/rhythm/set") && response.ok(),
|
|
||||||
);
|
|
||||||
await time.fill("07:30");
|
|
||||||
await moved;
|
|
||||||
await expect(time).toHaveValue("07:30");
|
|
||||||
const weekly = panel.getByTestId("heute-rhythm-weekly");
|
|
||||||
const weeklyOn = page.waitForResponse(
|
|
||||||
(response) => response.url().includes("/rpc/coach/rhythm/set") && response.ok(),
|
|
||||||
);
|
|
||||||
await weekly.getByRole("switch").click();
|
|
||||||
await weeklyOn;
|
|
||||||
await expect(weekly.getByRole("combobox")).toHaveValue("0");
|
|
||||||
await captureScreenshot(page, testInfo, "heute-rhythm");
|
|
||||||
|
|
||||||
expect(await rpc<CoachRhythm>(page, "coach/rhythm/get", {})).toEqual({
|
|
||||||
morning: "07:30",
|
|
||||||
evening: null,
|
|
||||||
weekly: { day: 0, time: "18:00" },
|
|
||||||
});
|
|
||||||
const main = await rpc<{ id: string }>(page, "bots/ensureMain", {});
|
|
||||||
const routines = await rpc<Routine[]>(page, "routines/list", { botId: main.id });
|
|
||||||
expect(routines.map(({ name, coachKey }) => ({ name, coachKey }))).toEqual(
|
|
||||||
expect.arrayContaining([
|
|
||||||
{ name: "Morning brief", coachKey: "morning" },
|
|
||||||
{ name: "Weekly review", coachKey: "weekly" },
|
|
||||||
]),
|
|
||||||
);
|
|
||||||
expect(routines).toHaveLength(2);
|
|
||||||
|
|
||||||
// The main bot's routine list names the check-ins by their key, in the UI language.
|
|
||||||
await openMainBotThread(page);
|
|
||||||
await page.getByTitle("Agent computer").click();
|
|
||||||
await expect(page.getByText("Morning brief", { exact: true })).toBeVisible();
|
|
||||||
await expect(page.getByText("Weekly review", { exact: true })).toBeVisible();
|
|
||||||
});
|
|
||||||
|
|||||||
@ -1,159 +0,0 @@
|
|||||||
import { expect, test } from "@playwright/test";
|
|
||||||
import { captureScreenshot, completeOnboarding, signup } from "./helpers";
|
|
||||||
|
|
||||||
test("the in-app browser previews a page and follows it back to the browser", async ({
|
|
||||||
page,
|
|
||||||
}, testInfo) => {
|
|
||||||
await signup(page, `in-app-browser-${Date.now()}@rakazo.test`, "password12", "Previewer");
|
|
||||||
await completeOnboarding(page);
|
|
||||||
// Context-wide so the "Open in browser" tab is served too.
|
|
||||||
await page.context().route("https://preview.example/**", (route) =>
|
|
||||||
route.fulfill({
|
|
||||||
contentType: "text/html",
|
|
||||||
body: `<!doctype html><title>Preview</title><body style="font:16px system-ui;padding:24px">
|
|
||||||
<h1>Live preview</h1><p>Rendered inside the app.</p>`,
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
await page.getByRole("button", { name: "Browser" }).click();
|
|
||||||
const browser = page.getByTestId("in-app-browser");
|
|
||||||
const address = browser.getByRole("textbox", { name: "Address" });
|
|
||||||
await expect(address).toBeFocused();
|
|
||||||
|
|
||||||
await address.fill("not an address");
|
|
||||||
await address.press("Enter");
|
|
||||||
await expect(address).toHaveAttribute("aria-invalid", "true");
|
|
||||||
|
|
||||||
await address.fill("preview.example/app");
|
|
||||||
await address.press("Enter");
|
|
||||||
await expect(address).toHaveValue("https://preview.example/app");
|
|
||||||
const frame = browser.getByTestId("in-app-browser-frame");
|
|
||||||
await expect(frame).toHaveAttribute("src", "https://preview.example/app");
|
|
||||||
await expect(
|
|
||||||
page.frameLocator('[data-testid="in-app-browser-frame"]').getByRole("heading"),
|
|
||||||
).toHaveText("Live preview");
|
|
||||||
await captureScreenshot(page, testInfo, "in-app-browser");
|
|
||||||
|
|
||||||
const popup = page.waitForEvent("popup");
|
|
||||||
await browser.getByRole("button", { name: "Open in browser" }).click();
|
|
||||||
expect((await popup).url()).toBe("https://preview.example/app");
|
|
||||||
|
|
||||||
await browser.getByRole("button", { name: "Close panel" }).click();
|
|
||||||
await expect(browser).toHaveCount(0);
|
|
||||||
// The last address comes back when the browser reopens.
|
|
||||||
await page.getByRole("button", { name: "Browser" }).click();
|
|
||||||
await expect(page.getByTestId("in-app-browser-frame")).toHaveAttribute(
|
|
||||||
"src",
|
|
||||||
"https://preview.example/app",
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("on desktop the page is a native view glued to the panel", async ({ page }) => {
|
|
||||||
await page.addInitScript(() => {
|
|
||||||
const calls: Array<[string, unknown?]> = [];
|
|
||||||
let listener: ((state: unknown) => void) | undefined;
|
|
||||||
let state = {
|
|
||||||
url: "",
|
|
||||||
title: "",
|
|
||||||
loading: false,
|
|
||||||
canGoBack: false,
|
|
||||||
canGoForward: false,
|
|
||||||
error: null as string | null,
|
|
||||||
};
|
|
||||||
const record = (name: string) => async (value?: unknown) => {
|
|
||||||
calls.push([name, value]);
|
|
||||||
};
|
|
||||||
Object.assign(window, { __browserCalls: calls });
|
|
||||||
Object.defineProperty(window, "rakazoDesktop", {
|
|
||||||
value: {
|
|
||||||
platform: "linux",
|
|
||||||
window: {
|
|
||||||
close: async () => {},
|
|
||||||
minimize: async () => {},
|
|
||||||
toggleMaximize: async () => {},
|
|
||||||
state: async () => ({ minimized: false, maximized: false, fullScreen: false }),
|
|
||||||
},
|
|
||||||
oauth: { onCallback: () => () => {} },
|
|
||||||
update: {
|
|
||||||
state: async () => ({ phase: "unsupported", currentVersion: "0.0.0" }),
|
|
||||||
check: async () => ({ phase: "unsupported", currentVersion: "0.0.0" }),
|
|
||||||
download: async () => ({ phase: "unsupported", currentVersion: "0.0.0" }),
|
|
||||||
install: async () => ({ phase: "unsupported", currentVersion: "0.0.0" }),
|
|
||||||
},
|
|
||||||
browserPreview: {
|
|
||||||
state: async () => state,
|
|
||||||
onState: (next: (value: unknown) => void) => {
|
|
||||||
listener = next;
|
|
||||||
return () => {
|
|
||||||
listener = undefined;
|
|
||||||
};
|
|
||||||
},
|
|
||||||
navigate: async (url: string) => {
|
|
||||||
calls.push(["navigate", url]);
|
|
||||||
state = { ...state, url, title: "Dev server", loading: false, canGoBack: true };
|
|
||||||
listener?.(state);
|
|
||||||
},
|
|
||||||
show: record("show"),
|
|
||||||
hide: record("hide"),
|
|
||||||
back: record("back"),
|
|
||||||
forward: record("forward"),
|
|
||||||
reload: record("reload"),
|
|
||||||
stop: record("stop"),
|
|
||||||
openDevTools: record("openDevTools"),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
});
|
|
||||||
await signup(page, `native-browser-${Date.now()}@rakazo.test`, "password12", "Desktop");
|
|
||||||
await completeOnboarding(page);
|
|
||||||
const calls = () =>
|
|
||||||
page.evaluate(
|
|
||||||
() => (window as unknown as { __browserCalls: Array<[string, unknown?]> }).__browserCalls,
|
|
||||||
);
|
|
||||||
|
|
||||||
await page.getByRole("button", { name: "Browser" }).click();
|
|
||||||
const browser = page.getByTestId("in-app-browser");
|
|
||||||
await browser.getByRole("textbox", { name: "Address" }).fill("localhost:5173");
|
|
||||||
await browser.getByRole("textbox", { name: "Address" }).press("Enter");
|
|
||||||
await expect.poll(calls).toContainEqual(["navigate", "http://localhost:5173/"]);
|
|
||||||
await expect(browser.getByRole("button", { name: "Back" })).toBeEnabled();
|
|
||||||
await expect(browser.getByRole("button", { name: "Forward" })).toBeDisabled();
|
|
||||||
await expect(browser.getByTestId("in-app-browser-frame")).toHaveCount(0);
|
|
||||||
|
|
||||||
// The reserved page area and the native view's bounds stay identical.
|
|
||||||
const viewport = browser.getByTestId("in-app-browser-viewport");
|
|
||||||
await expect
|
|
||||||
.poll(async () => {
|
|
||||||
const box = (await viewport.boundingBox())!;
|
|
||||||
const shown = (await calls()).filter(([name]) => name === "show").at(-1)?.[1];
|
|
||||||
return (
|
|
||||||
JSON.stringify(shown) ===
|
|
||||||
JSON.stringify({
|
|
||||||
x: Math.round(box.x),
|
|
||||||
y: Math.round(box.y),
|
|
||||||
width: Math.round(box.width),
|
|
||||||
height: Math.round(box.height),
|
|
||||||
})
|
|
||||||
);
|
|
||||||
})
|
|
||||||
.toBe(true);
|
|
||||||
|
|
||||||
await browser.getByRole("button", { name: "Back" }).click();
|
|
||||||
await browser.getByRole("button", { name: "Reload" }).click();
|
|
||||||
await browser.getByRole("button", { name: "Developer tools" }).click();
|
|
||||||
await expect
|
|
||||||
.poll(async () => (await calls()).map(([name]) => name))
|
|
||||||
.toEqual(expect.arrayContaining(["back", "reload", "openDevTools"]));
|
|
||||||
|
|
||||||
// App menus float above the page, so the native view steps aside while one is open.
|
|
||||||
const before = (await calls()).length;
|
|
||||||
await page.getByTestId("create-menu-trigger").click();
|
|
||||||
await expect
|
|
||||||
.poll(async () => (await calls()).slice(before).map(([name]) => name))
|
|
||||||
.toContain("hide");
|
|
||||||
await page.keyboard.press("Escape");
|
|
||||||
await expect.poll(async () => (await calls()).at(-1)?.[0]).toBe("show");
|
|
||||||
|
|
||||||
await browser.getByRole("button", { name: "Close panel" }).click();
|
|
||||||
await expect.poll(async () => (await calls()).at(-1)?.[0]).toBe("hide");
|
|
||||||
});
|
|
||||||
@ -39,75 +39,6 @@ test("answers a specialist's question from the main conversation", async ({ page
|
|||||||
await expect(page.getByText("Answered: Paris", { exact: true })).toBeVisible({ timeout: 30_000 });
|
await expect(page.getByText("Answered: Paris", { exact: true })).toBeVisible({ timeout: 30_000 });
|
||||||
});
|
});
|
||||||
|
|
||||||
test("answers a question from another space in the main conversation", async ({
|
|
||||||
page,
|
|
||||||
}, testInfo) => {
|
|
||||||
const stamp = Date.now();
|
|
||||||
await signup(page, `inbox-cross-${stamp}@rakazo.test`, "password12", "Inbox Cross");
|
|
||||||
await page.waitForURL(/\/(onboarding|app)/, { timeout: 30_000 });
|
|
||||||
const helper = await createBot(page, "Helper");
|
|
||||||
const side = await rpc<{ id: string }>(page, "spaces/create", { name: "Side project" });
|
|
||||||
const scout = await rpcInSpace<{ id: string }>(page, side.id, "bots/create", {
|
|
||||||
name: "Scout",
|
|
||||||
title: "",
|
|
||||||
description: "",
|
|
||||||
notifyOnFinish: true,
|
|
||||||
computerMode: "team",
|
|
||||||
});
|
|
||||||
await rpcInSpace(page, side.id, "threads/send", {
|
|
||||||
botId: scout.id,
|
|
||||||
text: "ask me which city to use",
|
|
||||||
});
|
|
||||||
const runInSide = async () =>
|
|
||||||
(
|
|
||||||
await rpcInSpace<{ run?: { status: string } | null }>(page, side.id, "threads/get", {
|
|
||||||
botId: scout.id,
|
|
||||||
})
|
|
||||||
).run ?? null;
|
|
||||||
await expect
|
|
||||||
.poll(async () => (await runInSide())?.status ?? null, { timeout: 60_000 })
|
|
||||||
.toBe("waiting_input");
|
|
||||||
|
|
||||||
await openMainConversation(page, helper.id);
|
|
||||||
const inbox = page.getByRole("region", { name: "Needs you" });
|
|
||||||
const ask = inbox.locator("[data-kind='ask']");
|
|
||||||
await expect(ask).toContainText("Scout", { timeout: 30_000 });
|
|
||||||
await expect(ask.getByTestId("main-inbox-space")).toHaveText("· Side project");
|
|
||||||
await captureScreenshot(page, testInfo, "inbox-cross-space");
|
|
||||||
|
|
||||||
await ask.getByRole("button", { name: "Edit first" }).click();
|
|
||||||
await ask.getByRole("textbox", { name: "Answer" }).fill("Paris");
|
|
||||||
await ask.getByRole("button", { name: "Send answer" }).click();
|
|
||||||
await expect(ask).toHaveCount(0);
|
|
||||||
await expect.poll(runInSide, { timeout: 60_000 }).toBeNull();
|
|
||||||
|
|
||||||
// The reply arrives as unread from the other space and opens there.
|
|
||||||
const reply = inbox.locator("[data-kind='unread']").filter({ hasText: "Scout" });
|
|
||||||
await expect(reply).toContainText("Paris", { timeout: 45_000 });
|
|
||||||
await expect(reply.getByTestId("main-inbox-space")).toHaveText("· Side project");
|
|
||||||
await reply.getByRole("button").click();
|
|
||||||
await page.waitForURL(new RegExp(`/app/${scout.id}$`));
|
|
||||||
await expect(page.getByText("Answered: Paris", { exact: true })).toBeVisible({ timeout: 30_000 });
|
|
||||||
expect(await page.evaluate(() => window.localStorage.getItem("rakazo:space-id"))).toBe(side.id);
|
|
||||||
});
|
|
||||||
|
|
||||||
async function rpcInSpace<T = unknown>(
|
|
||||||
page: Page,
|
|
||||||
spaceId: string,
|
|
||||||
procedure: string,
|
|
||||||
body: unknown,
|
|
||||||
): Promise<T> {
|
|
||||||
const response = await page.request.post(`/rpc/${procedure}`, {
|
|
||||||
data: { json: body },
|
|
||||||
headers: { "x-rakazo-space-id": spaceId },
|
|
||||||
});
|
|
||||||
const parsed = (await response.json()) as { json?: T; error?: { message?: string } };
|
|
||||||
if (!response.ok() || parsed.error) {
|
|
||||||
throw new Error(`${procedure} ${response.status()}: ${parsed.error?.message ?? "failed"}`);
|
|
||||||
}
|
|
||||||
return parsed.json as T;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function createBot(page: Page, name: string) {
|
async function createBot(page: Page, name: string) {
|
||||||
return rpc<{ id: string }>(page, "bots/create", {
|
return rpc<{ id: string }>(page, "bots/create", {
|
||||||
name,
|
name,
|
||||||
|
|||||||
@ -114,36 +114,6 @@ test("memory and skills are readable and editable in the app", async ({ page },
|
|||||||
await expect(botDocEditor).toHaveValue(new RegExp(botMarker));
|
await expect(botDocEditor).toHaveValue(new RegExp(botMarker));
|
||||||
await botMemory.getByRole("button", { name: "Cancel", exact: true }).click();
|
await botMemory.getByRole("button", { name: "Cancel", exact: true }).click();
|
||||||
|
|
||||||
// Remembering appends a dated note and keeps the edit above.
|
|
||||||
await rpc(page, "threads/send", {
|
|
||||||
botId: activeBotId(page),
|
|
||||||
text: `remember that the tester ${stamp} prefers tea`,
|
|
||||||
});
|
|
||||||
await expect
|
|
||||||
.poll(
|
|
||||||
async () =>
|
|
||||||
(
|
|
||||||
await rpc<MemoryDocument[]>(page, "memory/list", {
|
|
||||||
botId: activeBotId(page),
|
|
||||||
scope: "bot",
|
|
||||||
})
|
|
||||||
).find((doc) => doc.path === "MEMORY.md")?.content,
|
|
||||||
{ timeout: 60_000 },
|
|
||||||
)
|
|
||||||
.toMatch(
|
|
||||||
new RegExp(
|
|
||||||
`^# Chief\\n\\n${botMarker}\\n\\n- \\d{4}-\\d{2}-\\d{2}: .*tester ${stamp} prefers tea\\n$`,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
await expect
|
|
||||||
.poll(
|
|
||||||
async () =>
|
|
||||||
(await rpc<{ run?: unknown }>(page, "threads/get", { botId: activeBotId(page) })).run ??
|
|
||||||
null,
|
|
||||||
{ timeout: 60_000 },
|
|
||||||
)
|
|
||||||
.toBeNull();
|
|
||||||
|
|
||||||
// Skills: create one through the editor, reopen it, edit, then delete it.
|
// Skills: create one through the editor, reopen it, edit, then delete it.
|
||||||
// Builtin catalog is currently empty; user skills still cover create/edit/delete.
|
// Builtin catalog is currently empty; user skills still cover create/edit/delete.
|
||||||
await knowledge.getByRole("tab", { name: "Skills", exact: true }).click();
|
await knowledge.getByRole("tab", { name: "Skills", exact: true }).click();
|
||||||
|
|||||||
@ -75,11 +75,7 @@ test("local settings open and save integrations without an app session", async (
|
|||||||
});
|
});
|
||||||
|
|
||||||
test("ordinary browsers cannot use local settings", async ({ page }) => {
|
test("ordinary browsers cannot use local settings", async ({ page }) => {
|
||||||
await expect(async () => {
|
await page.goto("/desktop-settings");
|
||||||
await page.goto("/desktop-settings", { waitUntil: "domcontentloaded" });
|
await expect(page.getByRole("alert")).toContainText("Could not open local settings");
|
||||||
await expect(page.getByRole("alert")).toContainText("Could not open local settings", {
|
|
||||||
timeout: 5_000,
|
|
||||||
});
|
|
||||||
}).toPass({ timeout: 45_000 });
|
|
||||||
await expect(page.getByRole("button", { name: "Models", exact: true })).toBeHidden();
|
await expect(page.getByRole("button", { name: "Models", exact: true })).toBeHidden();
|
||||||
});
|
});
|
||||||
|
|||||||
@ -19,10 +19,7 @@ test("onboarding lands in the main conversation, its lead answers, and addressed
|
|||||||
const homeSpaceId = await page.evaluate(() => window.localStorage.getItem("rakazo:space-id"));
|
const homeSpaceId = await page.evaluate(() => window.localStorage.getItem("rakazo:space-id"));
|
||||||
const chief = await rpc<{ id: string }>(page, "bots/ensureMain", {});
|
const chief = await rpc<{ id: string }>(page, "bots/ensureMain", {});
|
||||||
const sidebar = page.locator("aside").first();
|
const sidebar = page.locator("aside").first();
|
||||||
// Projects list their own lead's main conversation too; this is the pinned home one.
|
const mainEntry = sidebar.getByRole("button", { name: "Chief, main conversation" });
|
||||||
const mainEntry = sidebar
|
|
||||||
.getByRole("button", { name: "Chief, main conversation" })
|
|
||||||
.and(sidebar.locator("[data-main-conversation]"));
|
|
||||||
await expect(mainEntry).toBeVisible();
|
await expect(mainEntry).toBeVisible();
|
||||||
// The main bot is reached through the main conversation, not listed again.
|
// The main bot is reached through the main conversation, not listed again.
|
||||||
await expect(sidebar.locator(`[data-roster-bot-id="${chief.id}"]`)).toHaveCount(0);
|
await expect(sidebar.locator(`[data-roster-bot-id="${chief.id}"]`)).toHaveCount(0);
|
||||||
@ -81,8 +78,7 @@ test("onboarding lands in the main conversation, its lead answers, and addressed
|
|||||||
const dialog = page.getByRole("dialog", { name: "New project" });
|
const dialog = page.getByRole("dialog", { name: "New project" });
|
||||||
await dialog.getByLabel("Name").fill("Launch");
|
await dialog.getByLabel("Name").fill("Launch");
|
||||||
await dialog.getByRole("button", { name: "Create project", exact: true }).click();
|
await dialog.getByRole("button", { name: "Create project", exact: true }).click();
|
||||||
// The project opens its own main conversation.
|
await page.waitForURL(/\/app(?:\/)?$/);
|
||||||
await page.waitForURL((url) => /\/app\/g\/[^/]+$/.test(url.pathname) && url.href !== mainUrl);
|
|
||||||
await expect
|
await expect
|
||||||
.poll(() => page.evaluate(() => window.localStorage.getItem("rakazo:space-id")))
|
.poll(() => page.evaluate(() => window.localStorage.getItem("rakazo:space-id")))
|
||||||
.not.toBe(homeSpaceId);
|
.not.toBe(homeSpaceId);
|
||||||
|
|||||||
@ -1,211 +0,0 @@
|
|||||||
import type { Page } from "@playwright/test";
|
|
||||||
import { expect, test } from "@playwright/test";
|
|
||||||
import {
|
|
||||||
activeBotId,
|
|
||||||
captureScreenshot,
|
|
||||||
completeOnboarding,
|
|
||||||
fetchRpcEnvelope,
|
|
||||||
rpc,
|
|
||||||
signup,
|
|
||||||
} from "./helpers";
|
|
||||||
|
|
||||||
type MediaBlock = Record<string, unknown> & { kind: "media" };
|
|
||||||
type Snapshot = {
|
|
||||||
botId?: string;
|
|
||||||
threadId: string;
|
|
||||||
messages: Array<{ seq: number } & Record<string, unknown>>;
|
|
||||||
};
|
|
||||||
|
|
||||||
/** One second of a quiet 440 Hz tone as 8 kHz mono 16-bit WAV, small enough to inline. */
|
|
||||||
function toneWavBase64(): string {
|
|
||||||
const rate = 8000;
|
|
||||||
const samples = rate;
|
|
||||||
const buffer = Buffer.alloc(44 + samples * 2);
|
|
||||||
buffer.write("RIFF", 0);
|
|
||||||
buffer.writeUInt32LE(36 + samples * 2, 4);
|
|
||||||
buffer.write("WAVEfmt ", 8);
|
|
||||||
buffer.writeUInt32LE(16, 16);
|
|
||||||
buffer.writeUInt16LE(1, 20);
|
|
||||||
buffer.writeUInt16LE(1, 22);
|
|
||||||
buffer.writeUInt32LE(rate, 24);
|
|
||||||
buffer.writeUInt32LE(rate * 2, 28);
|
|
||||||
buffer.writeUInt16LE(2, 32);
|
|
||||||
buffer.writeUInt16LE(16, 34);
|
|
||||||
buffer.write("data", 36);
|
|
||||||
buffer.writeUInt32LE(samples * 2, 40);
|
|
||||||
for (let index = 0; index < samples; index += 1) {
|
|
||||||
buffer.writeInt16LE(
|
|
||||||
Math.round(Math.sin((2 * Math.PI * 440 * index) / rate) * 3000),
|
|
||||||
44 + index * 2,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return buffer.toString("base64");
|
|
||||||
}
|
|
||||||
|
|
||||||
/** A small grey gradient still, drawn by the browser so no binary fixture is needed. */
|
|
||||||
async function stillPngBase64(page: Page): Promise<string> {
|
|
||||||
return page.evaluate(() => {
|
|
||||||
const canvas = document.createElement("canvas");
|
|
||||||
canvas.width = 320;
|
|
||||||
canvas.height = 180;
|
|
||||||
const context = canvas.getContext("2d")!;
|
|
||||||
const gradient = context.createLinearGradient(0, 0, 320, 180);
|
|
||||||
gradient.addColorStop(0, "rgb(40, 40, 40)");
|
|
||||||
gradient.addColorStop(1, "rgb(200, 200, 200)");
|
|
||||||
context.fillStyle = gradient;
|
|
||||||
context.fillRect(0, 0, 320, 180);
|
|
||||||
return canvas.toDataURL("image/png").split(",")[1]!;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Media jobs run on a GPU server or ffmpeg that CI does not have, so the card is seeded: real
|
|
||||||
* artifacts are uploaded, and the bot thread's snapshot (from bootstrap on load, threads/get on
|
|
||||||
* refresh) carries a bot message with the job's card.
|
|
||||||
*/
|
|
||||||
async function seedMediaMessage(page: Page, botId: string, block: () => MediaBlock) {
|
|
||||||
const seed = (snapshot: Snapshot | undefined) => {
|
|
||||||
if (snapshot?.botId !== botId) return;
|
|
||||||
const seq = Math.max(0, ...snapshot.messages.map((message) => message.seq)) + 1;
|
|
||||||
snapshot.messages.push({
|
|
||||||
id: `media-message-${seq}`,
|
|
||||||
threadId: snapshot.threadId,
|
|
||||||
seq,
|
|
||||||
role: "bot",
|
|
||||||
botId,
|
|
||||||
blocks: [block()],
|
|
||||||
createdAt: new Date().toISOString(),
|
|
||||||
});
|
|
||||||
};
|
|
||||||
for (const procedure of ["bootstrap", "threads/get"]) {
|
|
||||||
await page.route(`**/rpc/${procedure}`, async (route) => {
|
|
||||||
const fetched = await fetchRpcEnvelope(route);
|
|
||||||
const json = fetched.body.json as (Snapshot & { thread?: Snapshot }) | undefined;
|
|
||||||
seed(procedure === "bootstrap" ? json?.thread : json);
|
|
||||||
// Fresh headers: the original content length no longer matches the patched body.
|
|
||||||
await route.fulfill({
|
|
||||||
status: fetched.status,
|
|
||||||
contentType: "application/json",
|
|
||||||
body: JSON.stringify(fetched.body),
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
test("renders a media job card while it runs and when it is done", async ({ page }, testInfo) => {
|
|
||||||
const stamp = Date.now();
|
|
||||||
await signup(page, `media-card-${stamp}@rakazo.test`, "password12", "Media Card");
|
|
||||||
await completeOnboarding(page);
|
|
||||||
const botId = activeBotId(page);
|
|
||||||
|
|
||||||
const still = await rpc<{ id: string; size: number }>(page, "artifacts/create", {
|
|
||||||
botId,
|
|
||||||
name: "still.png",
|
|
||||||
mimeType: "image/png",
|
|
||||||
contentBase64: await stillPngBase64(page),
|
|
||||||
});
|
|
||||||
const voice = await rpc<{ id: string; size: number }>(page, "artifacts/create", {
|
|
||||||
botId,
|
|
||||||
name: "voice-note.wav",
|
|
||||||
mimeType: "audio/wav",
|
|
||||||
contentBase64: toneWavBase64(),
|
|
||||||
});
|
|
||||||
|
|
||||||
let state: "running" | "done" = "running";
|
|
||||||
await seedMediaMessage(page, botId, () =>
|
|
||||||
state === "running"
|
|
||||||
? {
|
|
||||||
kind: "media",
|
|
||||||
jobId: "media-job-1",
|
|
||||||
operation: "edit_video",
|
|
||||||
status: "running",
|
|
||||||
title: "Cut the pauses and reframe for 9:16",
|
|
||||||
progress: 0.4,
|
|
||||||
outputs: [],
|
|
||||||
}
|
|
||||||
: {
|
|
||||||
kind: "media",
|
|
||||||
jobId: "media-job-1",
|
|
||||||
operation: "edit_video",
|
|
||||||
status: "done",
|
|
||||||
title: "Cut the pauses and reframe for 9:16",
|
|
||||||
outputs: [
|
|
||||||
{
|
|
||||||
artifactId: still.id,
|
|
||||||
kind: "image",
|
|
||||||
mimeType: "image/png",
|
|
||||||
name: "still.png",
|
|
||||||
size: still.size,
|
|
||||||
width: 320,
|
|
||||||
height: 180,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
artifactId: voice.id,
|
|
||||||
kind: "audio",
|
|
||||||
mimeType: "audio/wav",
|
|
||||||
name: "voice-note.wav",
|
|
||||||
size: voice.size,
|
|
||||||
durationMs: 1000,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
);
|
|
||||||
// CI has no transcription server; the card asks what the space can run.
|
|
||||||
await page.route("**/rpc/media/capabilities", (route) =>
|
|
||||||
route.fulfill({
|
|
||||||
contentType: "application/json",
|
|
||||||
body: JSON.stringify({
|
|
||||||
json: {
|
|
||||||
generate_image: false,
|
|
||||||
generate_video: false,
|
|
||||||
edit_image: false,
|
|
||||||
edit_video: false,
|
|
||||||
export_video: true,
|
|
||||||
transcribe: true,
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
const started: unknown[] = [];
|
|
||||||
await page.route("**/rpc/media/jobs/start", async (route) => {
|
|
||||||
started.push((route.request().postDataJSON() as { json: unknown }).json);
|
|
||||||
await route.fulfill({
|
|
||||||
contentType: "application/json",
|
|
||||||
body: JSON.stringify({ json: { jobId: "media-job-2", messageId: "media-message-next" } }),
|
|
||||||
});
|
|
||||||
});
|
|
||||||
await page.reload();
|
|
||||||
|
|
||||||
const card = page.getByTestId("media-card");
|
|
||||||
await expect(card).toBeVisible({ timeout: 30_000 });
|
|
||||||
await expect(card).toHaveAttribute("data-status", "running");
|
|
||||||
await expect(card).toContainText("Cut the pauses and reframe for 9:16");
|
|
||||||
await expect(card).toContainText("Working…");
|
|
||||||
await expect(card.getByRole("progressbar")).toHaveAttribute("aria-valuenow", "40");
|
|
||||||
await captureScreenshot(page, testInfo, "media-card-running");
|
|
||||||
|
|
||||||
state = "done";
|
|
||||||
await page.reload();
|
|
||||||
await expect(card).toHaveAttribute("data-status", "done", { timeout: 30_000 });
|
|
||||||
await expect(card).toContainText("Done");
|
|
||||||
await expect(card.getByRole("progressbar")).toHaveCount(0);
|
|
||||||
await expect(card.getByRole("img", { name: "still.png" })).toBeVisible();
|
|
||||||
await expect(card.locator("audio")).toHaveCount(1);
|
|
||||||
await expect(card.getByRole("link", { name: "Download voice-note.wav" })).toHaveAttribute(
|
|
||||||
"download",
|
|
||||||
"voice-note.wav",
|
|
||||||
);
|
|
||||||
await expect(card.getByRole("button", { name: "Transcribe" })).toBeVisible();
|
|
||||||
// Export applies to video only.
|
|
||||||
await expect(card.getByRole("button", { name: "Export" })).toHaveCount(0);
|
|
||||||
await captureScreenshot(page, testInfo, "media-card-done");
|
|
||||||
|
|
||||||
await card.getByRole("button", { name: "Transcribe" }).click();
|
|
||||||
await expect.poll(() => started.length).toBe(1);
|
|
||||||
expect(started[0]).toEqual({
|
|
||||||
botId,
|
|
||||||
messageId: expect.stringMatching(/^media-message-\d+$/),
|
|
||||||
operation: "transcribe",
|
|
||||||
input: { artifact_id: voice.id, title: "voice-note.wav" },
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@ -1,82 +0,0 @@
|
|||||||
import path from "node:path";
|
|
||||||
import { fileURLToPath } from "node:url";
|
|
||||||
import { expect, test } from "@playwright/test";
|
|
||||||
import { captureScreenshot, completeOnboarding, signup } from "./helpers";
|
|
||||||
|
|
||||||
test.describe.configure({ mode: "serial" });
|
|
||||||
|
|
||||||
// VP9 in MP4: Playwright's Chromium has no H.264 decoder, real browsers play both.
|
|
||||||
const clip = path.join(path.dirname(fileURLToPath(import.meta.url)), "fixtures", "media-clip.mp4");
|
|
||||||
const pixel = Buffer.from(
|
|
||||||
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==",
|
|
||||||
"base64",
|
|
||||||
);
|
|
||||||
|
|
||||||
test("an uploaded video streams from the artifact route and plays inline", async ({
|
|
||||||
page,
|
|
||||||
}, testInfo) => {
|
|
||||||
const stamp = Date.now();
|
|
||||||
await signup(page, `media-video-${stamp}@rakazo.test`, "password12", "Media Video");
|
|
||||||
await completeOnboarding(page);
|
|
||||||
|
|
||||||
const upload = page.waitForResponse(
|
|
||||||
(response) =>
|
|
||||||
response.request().method() === "POST" && /\/api\/artifacts\?/.test(response.url()),
|
|
||||||
);
|
|
||||||
await page.locator('input[type="file"]').setInputFiles(clip);
|
|
||||||
await expect(page.getByRole("button", { name: "Remove media-clip.mp4" })).toBeVisible();
|
|
||||||
await page.getByRole("combobox", { name: /Message/ }).fill("here is the clip");
|
|
||||||
await page.keyboard.press("Enter");
|
|
||||||
expect((await upload).status()).toBe(201);
|
|
||||||
|
|
||||||
const video = page.getByTestId("media-video").locator("video");
|
|
||||||
await expect(video).toBeVisible({ timeout: 30_000 });
|
|
||||||
await expect(video).toHaveAttribute("src", /^\/api\/artifacts\/[^/?]+$/);
|
|
||||||
await expect
|
|
||||||
.poll(() => video.evaluate((element: HTMLVideoElement) => element.readyState), {
|
|
||||||
timeout: 15_000,
|
|
||||||
})
|
|
||||||
.toBeGreaterThanOrEqual(1);
|
|
||||||
expect(await video.evaluate((element: HTMLVideoElement) => element.duration)).toBeCloseTo(1, 0);
|
|
||||||
|
|
||||||
await video.evaluate(async (element: HTMLVideoElement) => {
|
|
||||||
element.muted = true;
|
|
||||||
await element.play();
|
|
||||||
});
|
|
||||||
await expect
|
|
||||||
.poll(() => video.evaluate((element: HTMLVideoElement) => element.currentTime))
|
|
||||||
.toBeGreaterThan(0);
|
|
||||||
await video.evaluate((element: HTMLVideoElement) => {
|
|
||||||
element.pause();
|
|
||||||
element.currentTime = 0.5;
|
|
||||||
element.scrollIntoView({ block: "center" });
|
|
||||||
});
|
|
||||||
await captureScreenshot(page, testInfo, "media-video-block");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("an image in the main conversation opens and downloads from the artifact route", async ({
|
|
||||||
page,
|
|
||||||
}) => {
|
|
||||||
const stamp = Date.now();
|
|
||||||
await signup(page, `media-image-${stamp}@rakazo.test`, "password12", "Media Image");
|
|
||||||
// The main conversation is a group thread, so this covers group uploads and reads.
|
|
||||||
await completeOnboarding(page, undefined, { mainConversation: true });
|
|
||||||
|
|
||||||
await page
|
|
||||||
.locator('input[type="file"]')
|
|
||||||
.setInputFiles({ name: "pixel.png", mimeType: "image/png", buffer: pixel });
|
|
||||||
await page.getByRole("combobox", { name: /Message/ }).fill("a pixel");
|
|
||||||
await page.keyboard.press("Enter");
|
|
||||||
|
|
||||||
const thumbnail = page.getByTestId("transcript").getByRole("img", { name: "pixel.png" });
|
|
||||||
await expect(thumbnail).toBeVisible({ timeout: 30_000 });
|
|
||||||
await expect(thumbnail).toHaveAttribute("src", /^\/api\/artifacts\/[^/?]+$/);
|
|
||||||
await thumbnail.click();
|
|
||||||
|
|
||||||
const dialog = page.getByRole("dialog", { name: "pixel.png" });
|
|
||||||
const download = dialog.getByRole("link", { name: "Download pixel.png" });
|
|
||||||
await expect(download).toHaveAttribute("href", /\/api\/artifacts\/[^/?]+\?download=1$/);
|
|
||||||
const saved = page.waitForEvent("download");
|
|
||||||
await download.click();
|
|
||||||
expect((await saved).suggestedFilename()).toBe("pixel.png");
|
|
||||||
});
|
|
||||||
@ -1,122 +0,0 @@
|
|||||||
import type { Page } from "@playwright/test";
|
|
||||||
import { expect, test } from "@playwright/test";
|
|
||||||
import { captureScreenshot, completeOnboarding, openUserSettings, signup } from "./helpers";
|
|
||||||
|
|
||||||
type Draft = {
|
|
||||||
comfyui: {
|
|
||||||
baseUrl: string;
|
|
||||||
apiKey?: string;
|
|
||||||
imageModel?: string;
|
|
||||||
imageWorkflow?: string;
|
|
||||||
videoWorkflow?: string;
|
|
||||||
} | null;
|
|
||||||
transcription: { baseUrl: string; model?: string; apiKey?: string } | null;
|
|
||||||
};
|
|
||||||
|
|
||||||
/** The server keeps keys; the settings screen only learns whether one is saved. */
|
|
||||||
function view(draft: Draft, previous: { comfyui: boolean; transcription: boolean }) {
|
|
||||||
const hasKey = (key: string | undefined, had: boolean) => (key === undefined ? had : key !== "");
|
|
||||||
const text = (value: string | undefined) => (value ? value : undefined);
|
|
||||||
return {
|
|
||||||
comfyui: draft.comfyui
|
|
||||||
? {
|
|
||||||
baseUrl: draft.comfyui.baseUrl,
|
|
||||||
hasApiKey: hasKey(draft.comfyui.apiKey, previous.comfyui),
|
|
||||||
imageModel: text(draft.comfyui.imageModel),
|
|
||||||
imageWorkflow: text(draft.comfyui.imageWorkflow),
|
|
||||||
videoWorkflow: text(draft.comfyui.videoWorkflow),
|
|
||||||
}
|
|
||||||
: null,
|
|
||||||
transcription: draft.transcription
|
|
||||||
? {
|
|
||||||
baseUrl: draft.transcription.baseUrl,
|
|
||||||
model: text(draft.transcription.model),
|
|
||||||
hasApiKey: hasKey(draft.transcription.apiKey, previous.transcription),
|
|
||||||
}
|
|
||||||
: null,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
async function asDeploymentOwner(page: Page, isDeploymentOwner: boolean) {
|
|
||||||
await page.route("**/rpc/bootstrap", async (route) => {
|
|
||||||
const response = await route.fetch();
|
|
||||||
const body = await response.json();
|
|
||||||
await route.fulfill({
|
|
||||||
response,
|
|
||||||
json: { json: { ...body.json, me: { ...body.json.me, isDeploymentOwner } } },
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
test("the deployment owner points media at their own servers", async ({ page }, testInfo) => {
|
|
||||||
await asDeploymentOwner(page, true);
|
|
||||||
let current: ReturnType<typeof view> = { comfyui: null, transcription: null };
|
|
||||||
const saved: Draft[] = [];
|
|
||||||
await page.route("**/rpc/media/settings/get", (route) =>
|
|
||||||
route.fulfill({ json: { json: current } }),
|
|
||||||
);
|
|
||||||
await page.route("**/rpc/media/settings/update", (route) => {
|
|
||||||
const draft = (route.request().postDataJSON() as { json: Draft }).json;
|
|
||||||
saved.push(draft);
|
|
||||||
current = view(draft, {
|
|
||||||
comfyui: Boolean(current.comfyui?.hasApiKey),
|
|
||||||
transcription: Boolean(current.transcription?.hasApiKey),
|
|
||||||
});
|
|
||||||
return route.fulfill({ json: { json: current } });
|
|
||||||
});
|
|
||||||
|
|
||||||
await signup(page, `media-settings-${Date.now()}@rakazo.test`, "password12", "Media Owner");
|
|
||||||
await completeOnboarding(page);
|
|
||||||
const settings = await openUserSettings(page);
|
|
||||||
await settings.getByTestId("settings-nav-media").click();
|
|
||||||
const panel = settings.getByTestId("media-settings");
|
|
||||||
await expect(panel).toBeVisible();
|
|
||||||
|
|
||||||
const comfy = panel.locator("section").filter({ hasText: "ComfyUI" });
|
|
||||||
const transcription = panel.locator("section").filter({ hasText: "Transcription" });
|
|
||||||
await comfy.getByLabel("Server URL", { exact: true }).fill("http://127.0.0.1:8188");
|
|
||||||
await comfy.getByLabel("API key", { exact: true }).fill("fake-comfy-key");
|
|
||||||
await transcription.getByLabel("Server URL", { exact: true }).fill("http://127.0.0.1:8080/v1");
|
|
||||||
await transcription.getByLabel("Model", { exact: true }).fill("whisper-1");
|
|
||||||
// The ComfyUI model and workflows stay folded away until asked for.
|
|
||||||
await expect(comfy.getByLabel("Image model", { exact: true })).toBeHidden();
|
|
||||||
await comfy.scrollIntoViewIfNeeded();
|
|
||||||
await captureScreenshot(page, testInfo, "media-settings");
|
|
||||||
await comfy.getByText("Advanced", { exact: true }).click();
|
|
||||||
await comfy.getByLabel("Image model", { exact: true }).fill("sd_xl_base_1.0.safetensors");
|
|
||||||
|
|
||||||
await panel.getByRole("button", { name: "Save", exact: true }).click();
|
|
||||||
await expect(panel.getByText("Saved", { exact: true })).toBeVisible();
|
|
||||||
expect(saved).toEqual([
|
|
||||||
{
|
|
||||||
comfyui: {
|
|
||||||
baseUrl: "http://127.0.0.1:8188",
|
|
||||||
apiKey: "fake-comfy-key",
|
|
||||||
imageModel: "sd_xl_base_1.0.safetensors",
|
|
||||||
imageWorkflow: "",
|
|
||||||
videoWorkflow: "",
|
|
||||||
},
|
|
||||||
transcription: { baseUrl: "http://127.0.0.1:8080/v1", model: "whisper-1" },
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
// The key never comes back; the field shows that one is saved and offers to remove it.
|
|
||||||
const comfyKey = comfy.getByLabel("API key", { exact: true });
|
|
||||||
await expect(comfyKey).toHaveValue("");
|
|
||||||
await expect(comfyKey).toHaveAttribute("placeholder", "Saved");
|
|
||||||
|
|
||||||
await comfy.getByRole("button", { name: "Remove", exact: true }).click();
|
|
||||||
await transcription.getByLabel("Server URL", { exact: true }).fill("");
|
|
||||||
await panel.getByRole("button", { name: "Save", exact: true }).click();
|
|
||||||
await expect.poll(() => saved.length).toBe(2);
|
|
||||||
expect(saved[1]).toMatchObject({ comfyui: { apiKey: "" }, transcription: null });
|
|
||||||
await expect(comfyKey).toHaveAttribute("placeholder", "Optional");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("members who do not own the deployment have no media settings", async ({ page }) => {
|
|
||||||
await asDeploymentOwner(page, false);
|
|
||||||
await signup(page, `media-member-${Date.now()}@rakazo.test`, "password12", "Media Member");
|
|
||||||
await completeOnboarding(page);
|
|
||||||
const settings = await openUserSettings(page);
|
|
||||||
await expect(settings.getByTestId("settings-nav-voice")).toBeVisible();
|
|
||||||
await expect(settings.getByTestId("settings-nav-media")).toHaveCount(0);
|
|
||||||
});
|
|
||||||
@ -4,8 +4,7 @@ import { captureScreenshot, completeOnboarding, signup, waitForChatComposer } fr
|
|||||||
/** Submit from the Send control so an in-flight prior send cannot swallow Enter. */
|
/** Submit from the Send control so an in-flight prior send cannot swallow Enter. */
|
||||||
async function sendComposerMessage(page: Page, composer: Locator, text: string) {
|
async function sendComposerMessage(page: Page, composer: Locator, text: string) {
|
||||||
await composer.fill(text);
|
await composer.fill(text);
|
||||||
// An open onboarding card in the transcript has its own Send.
|
const send = page.getByRole("button", { name: "Send", exact: true });
|
||||||
const send = page.getByTestId("composer-bar").getByRole("button", { name: "Send", exact: true });
|
|
||||||
await expect(send).toBeEnabled();
|
await expect(send).toBeEnabled();
|
||||||
await send.click();
|
await send.click();
|
||||||
await expect(composer).toHaveValue("");
|
await expect(composer).toHaveValue("");
|
||||||
@ -73,25 +72,14 @@ test("message hover shows beside-bubble actions; reply links to parent", async (
|
|||||||
|
|
||||||
const transcript = page.getByTestId("transcript");
|
const transcript = page.getByTestId("transcript");
|
||||||
|
|
||||||
// Empty start: no Fresh-start greeting. Answer the focus card, then ask something long: a bot
|
// Empty start: no Fresh-start greeting. Answer the focus card so a plain
|
||||||
// row's rail sits at the row's end, so the measured reply must wrap and fill its row.
|
// bot text bubble exists for hover layout checks.
|
||||||
await expect(
|
await expect(page.getByText("What do you want me on first?", { exact: true })).toBeVisible({
|
||||||
page.getByTestId("transcript").getByText("What do you want me on first?", { exact: true }),
|
|
||||||
).toBeVisible({
|
|
||||||
timeout: 20_000,
|
timeout: 20_000,
|
||||||
});
|
});
|
||||||
await page.getByTestId("focus-choice-day").click();
|
await page.getByTestId("focus-choice-day").click();
|
||||||
await expect(page.locator('[data-testid="choice-card"][data-step="goal"]')).toBeVisible({
|
const botText = page.getByText(/Got it\./);
|
||||||
timeout: 20_000,
|
await expect(botText).toBeVisible({ timeout: 20_000 });
|
||||||
});
|
|
||||||
const composer = page.getByRole("combobox", { name: /^Message/ });
|
|
||||||
await sendComposerMessage(
|
|
||||||
page,
|
|
||||||
composer,
|
|
||||||
`hover-bot-${stamp} ${"please look into this ".repeat(7)}`,
|
|
||||||
);
|
|
||||||
const botText = page.getByText(/i handled: hover-bot-/);
|
|
||||||
await expect(transcript.getByText(/i handled: hover-bot-/)).toBeVisible({ timeout: 20_000 });
|
|
||||||
const botRow = transcript.locator(`[data-message-id]`).filter({ has: botText }).first();
|
const botRow = transcript.locator(`[data-message-id]`).filter({ has: botText }).first();
|
||||||
await expect(botRow).toBeVisible();
|
await expect(botRow).toBeVisible();
|
||||||
await expectRailAtRest(page, botRow);
|
await expectRailAtRest(page, botRow);
|
||||||
@ -133,6 +121,7 @@ test("message hover shows beside-bubble actions; reply links to parent", async (
|
|||||||
|
|
||||||
const parentText = `hover-parent-${stamp}`;
|
const parentText = `hover-parent-${stamp}`;
|
||||||
const replyText = `hover-reply-${stamp}`;
|
const replyText = `hover-reply-${stamp}`;
|
||||||
|
const composer = page.getByRole("combobox", { name: /^Message/ });
|
||||||
await expect(composer).toBeVisible();
|
await expect(composer).toBeVisible();
|
||||||
await sendComposerMessage(page, composer, parentText);
|
await sendComposerMessage(page, composer, parentText);
|
||||||
|
|
||||||
@ -398,19 +387,12 @@ test.describe("touch message actions", () => {
|
|||||||
expect(
|
expect(
|
||||||
await page.evaluate(() => matchMedia("(hover: hover) and (pointer: fine)").matches),
|
await page.evaluate(() => matchMedia("(hover: hover) and (pointer: fine)").matches),
|
||||||
).toBe(false);
|
).toBe(false);
|
||||||
await expect(
|
await expect(page.getByText("What do you want me on first?", { exact: true })).toBeVisible({
|
||||||
page.getByTestId("transcript").getByText("What do you want me on first?", { exact: true }),
|
|
||||||
).toBeVisible({
|
|
||||||
timeout: 20_000,
|
timeout: 20_000,
|
||||||
});
|
});
|
||||||
await page.getByTestId("focus-choice-day").click();
|
await page.getByTestId("focus-choice-day").click();
|
||||||
// Onboarding writes no text of its own, so ask for a plain reply to act on.
|
const botText = page.getByText(/Got it\./);
|
||||||
const composer = page.getByRole("combobox", { name: /^Message/ });
|
await expect(botText).toBeVisible({ timeout: 20_000 });
|
||||||
await sendComposerMessage(page, composer, `touch-bot-${Date.now()}`);
|
|
||||||
const botText = page.getByText(/i handled: touch-bot-/);
|
|
||||||
await expect(page.getByTestId("transcript").getByText(/i handled: touch-bot-/)).toBeVisible({
|
|
||||||
timeout: 20_000,
|
|
||||||
});
|
|
||||||
const row = page
|
const row = page
|
||||||
.getByTestId("transcript")
|
.getByTestId("transcript")
|
||||||
.locator("[data-message-id]")
|
.locator("[data-message-id]")
|
||||||
|
|||||||
@ -42,12 +42,7 @@ test("create opens form, then empty chat; picker lists bots; sidebar collapses",
|
|||||||
await page.waitForURL(/\/app\/[^/]+$/);
|
await page.waitForURL(/\/app\/[^/]+$/);
|
||||||
await expect(page.getByRole("combobox", { name: "Message" })).toBeVisible();
|
await expect(page.getByRole("combobox", { name: "Message" })).toBeVisible();
|
||||||
await expect(page.getByTestId("side-panel")).toHaveAttribute("data-panel", "closed");
|
await expect(page.getByTestId("side-panel")).toHaveAttribute("data-panel", "closed");
|
||||||
await expect(
|
await expect(page.getByText("What do you want me on first?", { exact: true })).toHaveCount(0);
|
||||||
page.getByTestId("transcript").getByText("What do you want me on first?", { exact: true }),
|
|
||||||
).toHaveCount(0);
|
|
||||||
await expect(
|
|
||||||
page.getByTestId("transcript").getByText("Start the conversation — New Bot is on stage above."),
|
|
||||||
).toBeVisible();
|
|
||||||
await captureScreenshot(page, testInfo, "create-chat-sidepanel-closed");
|
await captureScreenshot(page, testInfo, "create-chat-sidepanel-closed");
|
||||||
|
|
||||||
await page.getByTestId("toggle-bots-sidebar").click();
|
await page.getByTestId("toggle-bots-sidebar").click();
|
||||||
@ -119,30 +114,20 @@ test("later bot waits before showing the focus card; sending cancels it", async
|
|||||||
await signup(page, `focus-delay-${stamp}@rakazo.test`, "password12", "Focus Delay");
|
await signup(page, `focus-delay-${stamp}@rakazo.test`, "password12", "Focus Delay");
|
||||||
await completeOnboarding(page, undefined, { mainConversation: true });
|
await completeOnboarding(page, undefined, { mainConversation: true });
|
||||||
// The main bot shows the focus card immediately in the main conversation.
|
// The main bot shows the focus card immediately in the main conversation.
|
||||||
await expect(
|
await expect(page.getByText("What do you want me on first?", { exact: true })).toBeVisible();
|
||||||
page.getByTestId("transcript").getByText("What do you want me on first?", { exact: true }),
|
|
||||||
).toBeVisible();
|
|
||||||
|
|
||||||
await page.clock.install();
|
await page.clock.install();
|
||||||
await createBotFromPicker(page);
|
await createBotFromPicker(page);
|
||||||
await expect(page.getByRole("combobox", { name: "Message" })).toBeVisible();
|
await expect(page.getByRole("combobox", { name: "Message" })).toBeVisible();
|
||||||
await expect(
|
await expect(page.getByText("What do you want me on first?", { exact: true })).toHaveCount(0);
|
||||||
page.getByTestId("transcript").getByText("What do you want me on first?", { exact: true }),
|
|
||||||
).toHaveCount(0);
|
|
||||||
|
|
||||||
await page.clock.fastForward(9_000);
|
await page.clock.fastForward(9_000);
|
||||||
await expect(
|
await expect(page.getByText("What do you want me on first?", { exact: true })).toHaveCount(0);
|
||||||
page.getByTestId("transcript").getByText("What do you want me on first?", { exact: true }),
|
|
||||||
).toHaveCount(0);
|
|
||||||
await page.clock.fastForward(1_500);
|
await page.clock.fastForward(1_500);
|
||||||
await expect(
|
await expect(page.getByText("What do you want me on first?", { exact: true })).toBeVisible();
|
||||||
page.getByTestId("transcript").getByText("What do you want me on first?", { exact: true }),
|
|
||||||
).toBeVisible();
|
|
||||||
|
|
||||||
await createBotFromPicker(page, { name: "Later Bot" });
|
await createBotFromPicker(page, { name: "Later Bot" });
|
||||||
await expect(
|
await expect(page.getByText("What do you want me on first?", { exact: true })).toHaveCount(0);
|
||||||
page.getByTestId("transcript").getByText("What do you want me on first?", { exact: true }),
|
|
||||||
).toHaveCount(0);
|
|
||||||
const composer = page.getByPlaceholder(/Message/);
|
const composer = page.getByPlaceholder(/Message/);
|
||||||
await composer.fill("I'll set this up myself");
|
await composer.fill("I'll set this up myself");
|
||||||
// Send must finish before the delay is advanced: cancel runs after a successful
|
// Send must finish before the delay is advanced: cancel runs after a successful
|
||||||
@ -156,9 +141,7 @@ test("later bot waits before showing the focus card; sending cancels it", async
|
|||||||
page.getByTestId("transcript").getByText("I'll set this up myself").first(),
|
page.getByTestId("transcript").getByText("I'll set this up myself").first(),
|
||||||
).toBeVisible();
|
).toBeVisible();
|
||||||
await page.clock.fastForward(12_000);
|
await page.clock.fastForward(12_000);
|
||||||
await expect(
|
await expect(page.getByText("What do you want me on first?", { exact: true })).toHaveCount(0);
|
||||||
page.getByTestId("transcript").getByText("What do you want me on first?", { exact: true }),
|
|
||||||
).toHaveCount(0);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test("plus picker can create a Private computer bot", async ({ page }, testInfo) => {
|
test("plus picker can create a Private computer bot", async ({ page }, testInfo) => {
|
||||||
|
|||||||
@ -1,130 +0,0 @@
|
|||||||
/**
|
|
||||||
* E2E: Settings → Notifications turns push on for this browser (the Push API is stubbed: headless
|
|
||||||
* Chromium has no push service), sets quiet hours and switches nudges off; all of it survives a
|
|
||||||
* reload.
|
|
||||||
*/
|
|
||||||
import type { Page } from "@playwright/test";
|
|
||||||
import { expect, test } from "@playwright/test";
|
|
||||||
import { captureScreenshot, completeOnboarding, openUserSettings, rpc, signup } from "./helpers";
|
|
||||||
|
|
||||||
const ENDPOINT = "https://push.example.test/e2e-device";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A fake Notification permission and service worker registration, kept in sessionStorage so they
|
|
||||||
* survive a reload. Headless Chromium reports notifications as denied even when granted.
|
|
||||||
*/
|
|
||||||
async function stubPushApi(page: Page, endpoint: string) {
|
|
||||||
await page.addInitScript((pushEndpoint) => {
|
|
||||||
const permissionKey = "e2e:notification-permission";
|
|
||||||
const notification = Object.defineProperties(function Notification() {}, {
|
|
||||||
permission: { get: () => sessionStorage.getItem(permissionKey) ?? "default" },
|
|
||||||
requestPermission: {
|
|
||||||
value: async () => {
|
|
||||||
sessionStorage.setItem(permissionKey, "granted");
|
|
||||||
return "granted";
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
Object.defineProperty(window, "Notification", {
|
|
||||||
configurable: true,
|
|
||||||
writable: true,
|
|
||||||
value: notification,
|
|
||||||
});
|
|
||||||
const storageKey = "e2e:push-subscription";
|
|
||||||
const current = () => {
|
|
||||||
const stored = sessionStorage.getItem(storageKey);
|
|
||||||
if (!stored) return null;
|
|
||||||
const saved = JSON.parse(stored) as { serverKey: number[] };
|
|
||||||
return {
|
|
||||||
endpoint: pushEndpoint,
|
|
||||||
options: { applicationServerKey: new Uint8Array(saved.serverKey).buffer },
|
|
||||||
toJSON: () => ({
|
|
||||||
endpoint: pushEndpoint,
|
|
||||||
keys: { p256dh: `B${"A".repeat(86)}`, auth: "A".repeat(22) },
|
|
||||||
}),
|
|
||||||
unsubscribe: async () => {
|
|
||||||
sessionStorage.removeItem(storageKey);
|
|
||||||
return true;
|
|
||||||
},
|
|
||||||
};
|
|
||||||
};
|
|
||||||
const registration = {
|
|
||||||
scope: `${location.origin}/`,
|
|
||||||
pushManager: {
|
|
||||||
getSubscription: async () => current(),
|
|
||||||
subscribe: async (options: { applicationServerKey: Uint8Array }) => {
|
|
||||||
sessionStorage.setItem(
|
|
||||||
storageKey,
|
|
||||||
JSON.stringify({ serverKey: [...new Uint8Array(options.applicationServerKey)] }),
|
|
||||||
);
|
|
||||||
return current();
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
Object.defineProperty(navigator, "serviceWorker", {
|
|
||||||
configurable: true,
|
|
||||||
value: {
|
|
||||||
register: async () => registration,
|
|
||||||
getRegistration: async () => registration,
|
|
||||||
ready: Promise.resolve(registration),
|
|
||||||
},
|
|
||||||
});
|
|
||||||
if (!("PushManager" in window)) {
|
|
||||||
Object.defineProperty(window, "PushManager", { configurable: true, value: class {} });
|
|
||||||
}
|
|
||||||
}, endpoint);
|
|
||||||
}
|
|
||||||
|
|
||||||
test("notification settings: push on this device, quiet hours and nudges", async ({
|
|
||||||
page,
|
|
||||||
}, testInfo) => {
|
|
||||||
await stubPushApi(page, ENDPOINT);
|
|
||||||
await signup(page, `notifications-${Date.now()}@rakazo.test`, "password12", "Push Tester");
|
|
||||||
await completeOnboarding(page);
|
|
||||||
|
|
||||||
let settings = await openUserSettings(page, "notifications");
|
|
||||||
const panel = settings.getByTestId("notification-settings");
|
|
||||||
const push = panel.getByTestId("notification-push-switch");
|
|
||||||
await expect(push).not.toBeChecked();
|
|
||||||
|
|
||||||
const subscribed = page.waitForRequest("**/rpc/notifications/webPush/subscribe");
|
|
||||||
await push.click();
|
|
||||||
expect((await subscribed).postDataJSON()).toMatchObject({ json: { endpoint: ENDPOINT } });
|
|
||||||
await expect(push).toBeChecked();
|
|
||||||
|
|
||||||
const quiet = panel.getByTestId("notification-quiet-hours-switch");
|
|
||||||
await expect(quiet).not.toBeChecked();
|
|
||||||
await quiet.click();
|
|
||||||
await expect(quiet).toBeChecked();
|
|
||||||
const start = panel.getByTestId("notification-quiet-hours-start");
|
|
||||||
await expect(start).toHaveValue("22:00");
|
|
||||||
await expect(panel.getByTestId("notification-quiet-hours-end")).toHaveValue("07:00");
|
|
||||||
const saved = page.waitForResponse(
|
|
||||||
(response) =>
|
|
||||||
response.url().includes("/rpc/notifications/settings/update") &&
|
|
||||||
response.request().postData()?.includes("23:30") === true,
|
|
||||||
);
|
|
||||||
await start.fill("23:30");
|
|
||||||
await saved;
|
|
||||||
|
|
||||||
const nudges = panel.getByTestId("notification-nudges-switch");
|
|
||||||
await expect(nudges).toBeChecked();
|
|
||||||
await nudges.click();
|
|
||||||
await expect(nudges).not.toBeChecked();
|
|
||||||
await expect
|
|
||||||
.poll(() => rpc<{ nudges: boolean }>(page, "notifications/settings/get", null))
|
|
||||||
.toMatchObject({ nudges: false, quietHours: { start: "23:30", end: "07:00" } });
|
|
||||||
|
|
||||||
await captureScreenshot(page, testInfo, "notification-settings");
|
|
||||||
|
|
||||||
await page.reload();
|
|
||||||
settings = await openUserSettings(page, "notifications");
|
|
||||||
await expect(settings.getByTestId("notification-push-switch")).toBeChecked();
|
|
||||||
await expect(settings.getByTestId("notification-quiet-hours-start")).toHaveValue("23:30");
|
|
||||||
await expect(settings.getByTestId("notification-nudges-switch")).not.toBeChecked();
|
|
||||||
|
|
||||||
const unsubscribed = page.waitForRequest("**/rpc/notifications/webPush/unsubscribe");
|
|
||||||
await settings.getByTestId("notification-push-switch").click();
|
|
||||||
expect((await unsubscribed).postDataJSON()).toMatchObject({ json: { endpoint: ENDPOINT } });
|
|
||||||
await expect(settings.getByTestId("notification-push-switch")).not.toBeChecked();
|
|
||||||
});
|
|
||||||
@ -16,16 +16,11 @@ test("focus choice suggests apps and preserves a completed connection", async ({
|
|||||||
await expect(
|
await expect(
|
||||||
page.getByText("Hey Robin. Fresh start on my side, so I’ll keep this short."),
|
page.getByText("Hey Robin. Fresh start on my side, so I’ll keep this short."),
|
||||||
).toHaveCount(0);
|
).toHaveCount(0);
|
||||||
await expect(
|
await expect(page.getByText("What do you want me on first?", { exact: true })).toBeVisible();
|
||||||
page.getByTestId("transcript").getByText("What do you want me on first?", { exact: true }),
|
|
||||||
).toBeVisible();
|
|
||||||
await expect(page.getByTestId("focus-choice-day")).toBeVisible();
|
await expect(page.getByTestId("focus-choice-day")).toBeVisible();
|
||||||
await expect(page.getByTestId("focus-choice-day")).toContainText("Day-to-day work");
|
await expect(page.getByTestId("focus-choice-day")).toContainText("Day-to-day work");
|
||||||
await expect(page.getByTestId("focus-choice-research")).toContainText("Research & writing");
|
await expect(page.getByTestId("focus-choice-research")).toContainText("Research & writing");
|
||||||
await expect(page.getByTestId("focus-choice-crew")).toContainText("Specialist crew");
|
await expect(page.getByTestId("focus-choice-crew")).toContainText("Specialist crew");
|
||||||
// Onboarding questions offer a quiet way out instead of a dismiss button.
|
|
||||||
await expect(page.getByTestId("focus-choice-skip")).toBeVisible();
|
|
||||||
await expect(page.getByRole("button", { name: "Dismiss" })).toHaveCount(0);
|
|
||||||
await page.mouse.move(1, 1);
|
await page.mouse.move(1, 1);
|
||||||
await captureScreenshot(page, testInfo, "01-focus-choice");
|
await captureScreenshot(page, testInfo, "01-focus-choice");
|
||||||
await captureScreenshot(page, testInfo, "choice-card-onboarding");
|
await captureScreenshot(page, testInfo, "choice-card-onboarding");
|
||||||
@ -38,8 +33,6 @@ test("focus choice suggests apps and preserves a completed connection", async ({
|
|||||||
).toBeVisible();
|
).toBeVisible();
|
||||||
expect((await rpc<{ name: string }>(page, "bots/ensureMain", {})).name).toBe("Chief");
|
expect((await rpc<{ name: string }>(page, "bots/ensureMain", {})).name).toBe("Chief");
|
||||||
await expect(page.getByRole("combobox", { name: "Message" })).toBeVisible();
|
await expect(page.getByRole("combobox", { name: "Message" })).toBeVisible();
|
||||||
// The app cards speak for themselves; onboarding adds no text around them.
|
|
||||||
await expect(page.getByText(/^Got it/)).toHaveCount(0);
|
|
||||||
await expect(page.getByText("Slack", { exact: true })).toBeVisible();
|
await expect(page.getByText("Slack", { exact: true })).toBeVisible();
|
||||||
await expect(page.getByText("Gmail", { exact: true })).toBeVisible();
|
await expect(page.getByText("Gmail", { exact: true })).toBeVisible();
|
||||||
const connectionCards = page.getByRole("group", { name: / connection$/ });
|
const connectionCards = page.getByRole("group", { name: / connection$/ });
|
||||||
@ -52,10 +45,10 @@ test("focus choice suggests apps and preserves a completed connection", async ({
|
|||||||
);
|
);
|
||||||
expect(cardBoxes[1].top - cardBoxes[0].bottom).toBeGreaterThanOrEqual(8);
|
expect(cardBoxes[1].top - cardBoxes[0].bottom).toBeGreaterThanOrEqual(8);
|
||||||
expect(cardBoxes[2].top - cardBoxes[1].bottom).toBeGreaterThanOrEqual(8);
|
expect(cardBoxes[2].top - cardBoxes[1].bottom).toBeGreaterThanOrEqual(8);
|
||||||
// The next onboarding question follows the suggestions.
|
await page
|
||||||
const goal = page.locator('[data-testid="choice-card"][data-step="goal"]');
|
.getByTestId("transcript")
|
||||||
await expect(goal).toContainText("What’s one thing you want to get done in the next few weeks?");
|
.getByText("Hit those three and I’ll start pulling the picture.")
|
||||||
await slackCard(page).scrollIntoViewIfNeeded();
|
.scrollIntoViewIfNeeded();
|
||||||
await page.mouse.move(1, 1);
|
await page.mouse.move(1, 1);
|
||||||
await captureScreenshot(page, testInfo, "02-app-suggestions");
|
await captureScreenshot(page, testInfo, "02-app-suggestions");
|
||||||
const authorizeButton = slackCard(page).getByRole("button", { name: "Authorize" });
|
const authorizeButton = slackCard(page).getByRole("button", { name: "Authorize" });
|
||||||
|
|||||||
@ -1,77 +0,0 @@
|
|||||||
/**
|
|
||||||
* E2E: the deployment owner's onboarding asks before scanning the fixture home folder the
|
|
||||||
* harness configures, then adds only the projects and facts the user picks.
|
|
||||||
*/
|
|
||||||
import type { Page } from "@playwright/test";
|
|
||||||
import { expect, test } from "@playwright/test";
|
|
||||||
import type { SpaceNavigation, UserProfileState } from "@rakazo/contracts";
|
|
||||||
import { captureScreenshot, rpc, signup } from "./helpers";
|
|
||||||
|
|
||||||
const api = process.env.API_URL ?? "http://127.0.0.1:3110";
|
|
||||||
|
|
||||||
/** Advances whichever wizard step is showing until the scan step is reached. */
|
|
||||||
async function walkToScanStep(page: Page) {
|
|
||||||
const surface = page.locator('[data-rakazo-surface="onboarding"]');
|
|
||||||
await expect(async () => {
|
|
||||||
if ((await surface.getAttribute("data-onboarding-step")) === "scan") return;
|
|
||||||
const skip = page.getByRole("button", { name: /^Skip/ }).first();
|
|
||||||
const next = (await skip.isVisible())
|
|
||||||
? skip
|
|
||||||
: page.getByRole("button", { name: "Continue", exact: true });
|
|
||||||
await next.click({ timeout: 2_000 });
|
|
||||||
await expect(surface).toHaveAttribute("data-onboarding-step", "scan", { timeout: 1_000 });
|
|
||||||
}).toPass({ timeout: 60_000 });
|
|
||||||
}
|
|
||||||
|
|
||||||
test("asks before looking for projects and adds only what the user picks", async ({
|
|
||||||
page,
|
|
||||||
}, testInfo) => {
|
|
||||||
const email = `discovery-${Date.now()}@rakazo.test`;
|
|
||||||
await signup(page, email, "password12", "Discovery");
|
|
||||||
await page.waitForURL(/\/onboarding/, { timeout: 20_000 });
|
|
||||||
// The harness starts without a deployment owner; this spec takes the seat for its run.
|
|
||||||
const owner = await page.request.post(`${api}/__e2e/deployment-owner`, { data: { email } });
|
|
||||||
expect(owner.ok()).toBe(true);
|
|
||||||
const { previousOwnerUserId } = (await owner.json()) as { previousOwnerUserId: string | null };
|
|
||||||
try {
|
|
||||||
await page.goto("/onboarding", { waitUntil: "domcontentloaded" });
|
|
||||||
await walkToScanStep(page);
|
|
||||||
|
|
||||||
await expect(page.getByRole("heading", { name: "Look for projects here?" })).toBeVisible();
|
|
||||||
await expect(page.getByText(/rakazo-e2e-scan-home-/)).toBeVisible();
|
|
||||||
await captureScreenshot(page, testInfo, "onboarding-discovery-consent");
|
|
||||||
await page.getByRole("button", { name: "Look", exact: true }).click();
|
|
||||||
|
|
||||||
const projects = page.getByRole("group", { name: "Add as projects" });
|
|
||||||
await expect(projects.getByRole("checkbox")).toHaveCount(2);
|
|
||||||
const atlas = projects.getByRole("checkbox", { name: /atlas/ });
|
|
||||||
await expect(atlas).not.toBeChecked();
|
|
||||||
await expect(projects.getByRole("checkbox", { name: /garden/ })).not.toBeChecked();
|
|
||||||
await atlas.check();
|
|
||||||
const facts = page.getByRole("group", { name: "Add to About you" });
|
|
||||||
await expect(facts.getByRole("checkbox", { name: "TypeScript" })).toBeChecked();
|
|
||||||
await facts.getByRole("checkbox", { name: "Python" }).uncheck();
|
|
||||||
await captureScreenshot(page, testInfo, "onboarding-discovery-results");
|
|
||||||
await page.getByRole("button", { name: "Add", exact: true }).click();
|
|
||||||
await page.waitForURL(/\/app\/g\/[^/]+$/, { timeout: 30_000 });
|
|
||||||
|
|
||||||
const navigation = await rpc<SpaceNavigation>(page, "spaces/list", {});
|
|
||||||
const names = navigation.spaces.map((space) => space.name);
|
|
||||||
expect(names).toContain("atlas");
|
|
||||||
expect(names).not.toContain("garden");
|
|
||||||
expect(navigation.spaces.find((space) => space.name === "atlas")?.workspace?.path).toMatch(
|
|
||||||
/code\/atlas$/,
|
|
||||||
);
|
|
||||||
const values = (await rpc<UserProfileState>(page, "profile/get", {})).facts.map(
|
|
||||||
(fact) => fact.value,
|
|
||||||
);
|
|
||||||
expect(values).toEqual(
|
|
||||||
expect.arrayContaining(["Codes in TypeScript", "Uses Docker", "Uses GitHub Actions"]),
|
|
||||||
);
|
|
||||||
expect(values).not.toContain("Codes in Python");
|
|
||||||
} finally {
|
|
||||||
await page.request.post(`${api}/__e2e/deployment-owner`, {
|
|
||||||
data: { userId: previousOwnerUserId },
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
x
Reference in New Issue
Block a user