Compare commits
100 Commits
claude/ama
...
claude/pha
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
86465067d6 | ||
|
|
0c3e3f3d29 | ||
|
|
ece3638ce7 | ||
|
|
59f4a1dfca | ||
|
|
735322b016 | ||
|
|
9d6d85cb52 | ||
|
|
31dcdf5293 | ||
|
|
a7ae619b28 | ||
|
|
a62e09f36b | ||
|
|
68f9dfb34b | ||
|
|
aea604f7ca | ||
|
|
c4c5ce3c2d | ||
|
|
4ba7d843b1 | ||
|
|
182a9288c2 | ||
|
|
f071c2257c | ||
|
|
52bbbbfb00 | ||
|
|
77e822b9a7 | ||
|
|
c7d543132d | ||
|
|
2c6b5c3d7b | ||
|
|
8449dec6c2 | ||
|
|
a51ba87133 | ||
|
|
0d1939c775 | ||
|
|
6e54c873c3 | ||
|
|
e1cd9be8a6 | ||
|
|
82fad0c9ac | ||
|
|
3edce64bad | ||
|
|
d912712668 | ||
|
|
e28c3acf02 | ||
|
|
c7510fa9b3 | ||
|
|
adb4dff052 | ||
|
|
3a72b80122 | ||
|
|
ee2c5fc3b9 | ||
|
|
a15da068d7 | ||
|
|
7532f17319 | ||
|
|
9eb4545793 | ||
|
|
e2cf548d18 | ||
|
|
09ed6c2136 | ||
|
|
c641936a2e | ||
|
|
49431def83 | ||
|
|
2273e8f82b | ||
|
|
28206f21ca | ||
|
|
ade6c5d116 | ||
|
|
f66ca4b7e6 | ||
|
|
dd5080ea36 | ||
|
|
bf23bd43c6 | ||
|
|
3c3961f242 | ||
|
|
2b967df569 | ||
|
|
ef15ec61b1 | ||
|
|
f8eabee997 | ||
|
|
7a32100b53 | ||
|
|
27f02c05af | ||
|
|
cf797ce6a5 | ||
|
|
06b05711fd | ||
|
|
056cabd4ae | ||
|
|
d3689b88fc | ||
|
|
0682f9b6f4 | ||
|
|
7c17694f5b | ||
|
|
2c3e8c612f | ||
|
|
07c37858ce | ||
|
|
a09e29d65a | ||
|
|
e72b169ca4 | ||
|
|
8d6ed7952f | ||
|
|
4fbd087ec8 | ||
|
|
245efc88b1 | ||
|
|
59d02c46fb | ||
|
|
97210805be | ||
|
|
74eb27447e | ||
|
|
469e6ec5d7 | ||
|
|
2c91a80f69 | ||
|
|
4191a4b6e6 | ||
|
|
2d95db70f2 | ||
|
|
dab9398c25 | ||
|
|
7a58bc7ba9 | ||
|
|
7185dcdd3c | ||
|
|
d63f7b3bf1 | ||
|
|
3db0f0cfbd | ||
|
|
1a3829031f | ||
|
|
e13dbe5ffa | ||
|
|
ddf13adf08 | ||
|
|
84dff4d846 | ||
|
|
dc259f0491 | ||
|
|
8ae07cdf11 | ||
|
|
a99e58be00 | ||
|
|
98b0798f60 | ||
|
|
ebcbf4c05f | ||
|
|
a094c23b47 | ||
|
|
2c88d09eea | ||
|
|
f4e02eb248 | ||
|
|
74503ad663 | ||
|
|
e1ce6f4090 | ||
|
|
b124d2d7e5 | ||
|
|
6ad9be6c1e | ||
|
|
a163447461 | ||
|
|
4b59b0e8b0 | ||
|
|
7d7af8f3e9 | ||
|
|
4875753d9c | ||
|
|
84ee83d7bd | ||
|
|
105a3094b1 | ||
|
|
c22a25d7ab | ||
|
|
693ab5e4ce |
@ -1,44 +0,0 @@
|
|||||||
# 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 the onboarding scan never touches the host filesystem.
|
# First-run discovery. Leave unset and onboarding never looks at the host filesystem.
|
||||||
# Set a home-style directory (its Projects, Documents, src, ... subfolders are scanned) to let the
|
# Set a folder (for example a home folder) and the deployment owner's onboarding offers to list the
|
||||||
# deployment owner's own onboarding discover local projects. Other accounts never scan the host.
|
# git projects in it, reading file and folder names only. Other accounts never see the step.
|
||||||
# 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,8 +166,12 @@ 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=
|
||||||
@ -210,26 +214,6 @@ 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,6 +246,7 @@ 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
|
||||||
@ -344,23 +345,30 @@ jobs:
|
|||||||
fi
|
fi
|
||||||
echo "publish=$publish" >> "$GITHUB_OUTPUT"
|
echo "publish=$publish" >> "$GITHUB_OUTPUT"
|
||||||
|
|
||||||
- name: Require Expo automation token
|
- name: Check 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
|
{
|
||||||
exit 1
|
echo "### Mobile OTA skipped"
|
||||||
|
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'
|
if: steps.ota.outputs.publish == 'true' && steps.expo_token.outputs.ready == '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'
|
if: steps.ota.outputs.publish == 'true' && steps.expo_token.outputs.ready == 'true'
|
||||||
shell: bash
|
shell: bash
|
||||||
run: |
|
run: |
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
@ -377,25 +385,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'
|
if: steps.ota.outputs.publish == 'true' && steps.expo_token.outputs.ready == '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'
|
if: steps.ota.outputs.publish == 'true' && steps.expo_token.outputs.ready == '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'
|
if: steps.ota.outputs.publish == 'true' && steps.expo_token.outputs.ready == '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'
|
if: steps.ota.outputs.publish == 'true' && steps.expo_token.outputs.ready == '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'
|
if: steps.ota.outputs.publish == 'true' && steps.expo_token.outputs.ready == '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 -r '.owner.type')"
|
owner_type="$(gh api "repos/${GITHUB_REPOSITORY}" --jq '.owner.type')"
|
||||||
private="$(gh api "repos/${GITHUB_REPOSITORY}" --jq -r '.private')"
|
private="$(gh api "repos/${GITHUB_REPOSITORY}" --jq '.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,7 +47,6 @@ 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/
|
||||||
|
|||||||
@ -1,238 +0,0 @@
|
|||||||
# 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`.
|
|
||||||
@ -10,6 +10,7 @@
|
|||||||
- Keep code simple: reuse existing primitives and one source of truth, remove duplication and unused flexibility, and avoid speculative abstractions. Add an interface when it protects a real external or platform boundary, not for its own sake.
|
- Keep code simple: reuse existing primitives and one source of truth, remove duplication and unused flexibility, and avoid speculative abstractions. Add an interface when it protects a real external or platform boundary, not for its own sake.
|
||||||
- Treat auth, secret handling, sandbox boundaries, host commands, and integrations as security-sensitive. Keep tests deterministic and offline by default.
|
- Treat auth, secret handling, sandbox boundaries, host commands, and integrations as security-sensitive. Keep tests deterministic and offline by default.
|
||||||
- The desktop Playwright suite (`pnpm --filter @rakazo/desktop test:e2e`) opens real Electron windows and steals focus on macOS. Do not run it on a maintainer's machine as routine verification; run the unit tests locally and let CI's virtual-display job run the e2e on push.
|
- The desktop Playwright suite (`pnpm --filter @rakazo/desktop test:e2e`) opens real Electron windows and steals focus on macOS. Do not run it on a maintainer's machine as routine verification; run the unit tests locally and let CI's virtual-display job run the e2e on push.
|
||||||
|
- Work autonomously. Make routine calls yourself, such as opening or reopening a pull request, linking CI screenshots, or fixing gaps you found that belong to the task, then verify and report the outcome. Ask the maintainer only before irreversible actions or for product decisions without a sensible default.
|
||||||
- After creating a pull request, stay with it until CI and automated review bots have finished; passing checks alone do not mean the review is complete. Follow `.agents/skills/pr-watch/SKILL.md`: run its `pr-digest --watch` helper as a single backgrounded call that blocks until the checks on the current head commit are terminal, then act on the verdict it prints. Do not poll in the foreground on a timer, which mostly buys repeated snapshots of an unchanged PR and dies at the tool timeout. Address every actionable issue, push the fixes, and repeat until no actionable feedback remains. Do not merge while review bots are still pending or review issues remain unresolved.
|
- After creating a pull request, stay with it until CI and automated review bots have finished; passing checks alone do not mean the review is complete. Follow `.agents/skills/pr-watch/SKILL.md`: run its `pr-digest --watch` helper as a single backgrounded call that blocks until the checks on the current head commit are terminal, then act on the verdict it prints. Do not poll in the foreground on a timer, which mostly buys repeated snapshots of an unchanged PR and dies at the tool timeout. Address every actionable issue, push the fixes, and repeat until no actionable feedback remains. Do not merge while review bots are still pending or review issues remain unresolved.
|
||||||
- PR descriptions say why the change exists, what changed, and how it was tested. Write Why as the product reason (what was wrong and what this changes); never "Elie wants" or a third-person briefing of a named person; the maintainer is the author.
|
- PR descriptions say why the change exists, what changed, and how it was tested. Write Why as the product reason (what was wrong and what this changes); never "Elie wants" or a third-person briefing of a named person; the maintainer is the author.
|
||||||
- Commit messages, PR descriptions, issues, and review replies are public too. Describe test results in words instead of pasting tool output, and never include anything that identifies a person, machine, or account: local paths, usernames, hostnames, signing identities, legal entity names, Apple Team IDs, API key IDs, issuer or tenant IDs, account emails, or the accounts, machines, and files that secret values came from. Naming the secrets a workflow reads is fine; use placeholders when a value must be shown.
|
- Commit messages, PR descriptions, issues, and review replies are public too. Describe test results in words instead of pasting tool output, and never include anything that identifies a person, machine, or account: local paths, usernames, hostnames, signing identities, legal entity names, Apple Team IDs, API key IDs, issuer or tenant IDs, account emails, or the accounts, machines, and files that secret values came from. Naming the secrets a workflow reads is fine; use placeholders when a value must be shown.
|
||||||
|
|||||||
@ -1,87 +0,0 @@
|
|||||||
# 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.
|
|
||||||
@ -1,72 +0,0 @@
|
|||||||
# 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
|
|
||||||
@ -1,12 +0,0 @@
|
|||||||
# 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 | `/Users/ingowagner/Projects/evOo-local` |
|
| Intended Mac path | `~/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_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 |
|
| Antigravity-Zukunftswerkzeuge: self-update, local-stack, pi-local-provider, seed-ollama, Voice First / Oo-Stage | Same Antigravity future tools kept on this branch |
|
||||||
|
|
||||||
## Quick start
|
## Quick start
|
||||||
|
|
||||||
@ -53,10 +53,7 @@ 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 (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`.
|
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`.
|
||||||
|
|
||||||
**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.
|
||||||
|
|
||||||
|
|||||||
@ -1,119 +0,0 @@
|
|||||||
# 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
|
|
||||||
```
|
|
||||||
@ -1,11 +0,0 @@
|
|||||||
# 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.
|
|
||||||
@ -1,29 +0,0 @@
|
|||||||
{
|
|
||||||
"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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,62 +0,0 @@
|
|||||||
"""
|
|
||||||
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.")
|
|
||||||
@ -1,40 +0,0 @@
|
|||||||
"""
|
|
||||||
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}")
|
|
||||||
@ -1,48 +0,0 @@
|
|||||||
"""
|
|
||||||
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
|
|
||||||
@ -1,52 +0,0 @@
|
|||||||
#!/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()
|
|
||||||
@ -1,5 +1,4 @@
|
|||||||
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 {
|
||||||
@ -35,13 +34,14 @@ import {
|
|||||||
destroyBot,
|
destroyBot,
|
||||||
EmailEmulator,
|
EmailEmulator,
|
||||||
EncryptedSecretStore,
|
EncryptedSecretStore,
|
||||||
ExpoPushProvider,
|
ExpoPushSender,
|
||||||
GraphileJobPublisher,
|
GraphileJobPublisher,
|
||||||
GraphileJobWorkerHost,
|
GraphileJobWorkerHost,
|
||||||
InMemoryJobQueue,
|
InMemoryJobQueue,
|
||||||
InMemoryRealtimeFanout,
|
InMemoryRealtimeFanout,
|
||||||
InstalledConnectorProvider,
|
InstalledConnectorProvider,
|
||||||
IntegrationProviderSettings,
|
IntegrationProviderSettings,
|
||||||
|
importLegacyPushTokens,
|
||||||
isComposioEnabled,
|
isComposioEnabled,
|
||||||
isMessagingSurfaceEnabled,
|
isMessagingSurfaceEnabled,
|
||||||
isPipedreamEnabled,
|
isPipedreamEnabled,
|
||||||
@ -54,9 +54,9 @@ import {
|
|||||||
PiOAuthLogins,
|
PiOAuthLogins,
|
||||||
PipedreamConnector,
|
PipedreamConnector,
|
||||||
PostgresRealtimeFanout,
|
PostgresRealtimeFanout,
|
||||||
|
PushNotificationProvider,
|
||||||
pipedreamConfigFromEnv,
|
pipedreamConfigFromEnv,
|
||||||
piSessionsRoot,
|
piSessionsRoot,
|
||||||
pushTokenPath,
|
|
||||||
reconcileCloudAgents,
|
reconcileCloudAgents,
|
||||||
reconcileComputerUpdates,
|
reconcileComputerUpdates,
|
||||||
reconcileDelegatedTasks,
|
reconcileDelegatedTasks,
|
||||||
@ -65,6 +65,9 @@ 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";
|
||||||
@ -109,7 +112,6 @@ 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";
|
||||||
|
|
||||||
@ -309,7 +311,14 @@ export async function createApp(
|
|||||||
: new PiAgentRuntime({
|
: new PiAgentRuntime({
|
||||||
sessionRoot: env.piSessionRecording ? piSessionsRoot(env.dataDir) : undefined,
|
sessionRoot: env.piSessionRecording ? piSessionsRoot(env.dataDir) : undefined,
|
||||||
});
|
});
|
||||||
const notifications = new ExpoPushProvider(env.dataDir);
|
const vapid = new VapidKeys(prisma, secrets, vapidEnvFromProcess(process.env));
|
||||||
|
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,
|
||||||
@ -365,7 +374,6 @@ 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.
|
||||||
@ -463,6 +471,7 @@ 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,
|
||||||
@ -530,13 +539,6 @@ 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",
|
||||||
@ -567,7 +569,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 },
|
context: { actor, signal: c.req.raw.signal, userAgent: c.req.header("user-agent") },
|
||||||
});
|
});
|
||||||
if (matched) return c.newResponse(response.body, response);
|
if (matched) return c.newResponse(response.body, response);
|
||||||
await next();
|
await next();
|
||||||
|
|||||||
96
apps/api/src/coach.ts
Normal file
96
apps/api/src/coach.ts
Normal file
@ -0,0 +1,96 @@
|
|||||||
|
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 }),
|
||||||
|
};
|
||||||
|
}
|
||||||
50
apps/api/src/connected-app-facts.ts
Normal file
50
apps/api/src/connected-app-facts.ts
Normal file
@ -0,0 +1,50 @@
|
|||||||
|
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,16 +42,6 @@ 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,7 +8,6 @@ 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";
|
||||||
@ -93,8 +92,6 @@ 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 {
|
||||||
@ -182,7 +179,6 @@ 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,9 +18,11 @@ 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,
|
||||||
@ -48,8 +50,10 @@ 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
|
||||||
@ -227,4 +231,64 @@ 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; a space rarely holds more waiting runs or unread threads than this. */
|
/** Caps per query; the user's spaces rarely hold 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,9 +12,11 @@ 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;
|
||||||
@ -33,6 +35,7 @@ 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. */
|
||||||
@ -73,12 +76,14 @@ function previewText(blocks: unknown): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Where an ask can be answered from. The main conversation already shows its own asks, and an
|
* Where an ask can be answered from. The open space's main conversation already shows its own
|
||||||
* archived group or an external conversation has no thread target to answer through.
|
* asks (a project's main conversation does not), and an archived group or an external
|
||||||
|
* conversation has no thread target to answer through.
|
||||||
*/
|
*/
|
||||||
function askTarget(thread: InboxRunRow["thread"]): InboxThreadTarget | null {
|
function askTarget(thread: InboxRunRow["thread"], currentSpace: boolean): InboxThreadTarget | null {
|
||||||
if (thread.groupId) {
|
if (thread.groupId) {
|
||||||
if (!thread.group || thread.group.kind === "main" || thread.group.archivedAt) return null;
|
if (!thread.group || 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;
|
||||||
@ -95,14 +100,20 @@ 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.
|
* oldest first. Items from another space name it.
|
||||||
*/
|
*/
|
||||||
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;
|
||||||
@ -115,13 +126,14 @@ 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);
|
const target = askTarget(run.thread, !input.otherSpaces?.has(run.spaceId));
|
||||||
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,
|
||||||
@ -135,6 +147,7 @@ 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 ?? "",
|
||||||
@ -154,6 +167,7 @@ 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(),
|
||||||
@ -165,13 +179,34 @@ 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: actor.spaceId,
|
spaceId: { in: spaceIds },
|
||||||
userId: actor.userId,
|
userId: actor.userId,
|
||||||
status: { in: ["waiting_input", "waiting_takeover"] },
|
status: { in: ["waiting_input", "waiting_takeover"] },
|
||||||
bot: ownBot,
|
bot: ownBot,
|
||||||
@ -179,6 +214,7 @@ 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,
|
||||||
@ -197,12 +233,13 @@ 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: actor.spaceId,
|
spaceId: { in: spaceIds },
|
||||||
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: {
|
||||||
@ -232,5 +269,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 });
|
return buildInboxItems({ runs, messages, unreadThreads, otherSpaces });
|
||||||
}
|
}
|
||||||
|
|||||||
@ -43,13 +43,20 @@ export async function assertNotMainConversation(
|
|||||||
if (group?.kind === "main") throw new ORPCError("BAD_REQUEST", { message });
|
if (group?.kind === "main") throw new ORPCError("BAD_REQUEST", { message });
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The main conversation carries the main bot's name. */
|
/** A main conversation carries its main bot's name. */
|
||||||
export async function renameMainConversation(prisma: PrismaClient, userId: string, name: string) {
|
export async function renameMainConversation(
|
||||||
await prisma.chatGroup.updateMany({ where: { userId, kind: "main" }, data: { name } });
|
prisma: PrismaClient,
|
||||||
|
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 the main conversation, by `@Name` or a mention chip,
|
* Same-space bots the user addresses in a 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(
|
||||||
@ -71,5 +78,9 @@ 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, { userId: actor.userId, botIds: addressed });
|
await joinMainConversation(prisma, {
|
||||||
|
userId: actor.userId,
|
||||||
|
spaceId: actor.spaceId,
|
||||||
|
botIds: addressed,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
108
apps/api/src/onboarding-intake.test.ts
Normal file
108
apps/api/src/onboarding-intake.test.ts
Normal file
@ -0,0 +1,108 @@
|
|||||||
|
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",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
297
apps/api/src/onboarding-intake.ts
Normal file
297
apps/api/src/onboarding-intake.ts
Normal file
@ -0,0 +1,297 @@
|
|||||||
|
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,64 +1,192 @@
|
|||||||
import { mkdir, mkdtemp, writeFile } from "node:fs/promises";
|
import { mkdir, mkdtemp, realpath, rm, symlink, writeFile } from "node:fs/promises";
|
||||||
import { tmpdir } from "node:os";
|
import { tmpdir } from "node:os";
|
||||||
import { join } from "node:path";
|
import path from "node:path";
|
||||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
import type { PrismaClient } from "@rakazo/db";
|
||||||
import { getScanStatus, startOnboardingScan } from "./onboarding-scan.js";
|
import { afterAll, describe, expect, it, vi } from "vitest";
|
||||||
|
import {
|
||||||
|
applyOnboardingScan,
|
||||||
|
discoverOnboardingScan,
|
||||||
|
onboardingScanFolder,
|
||||||
|
onboardingScanStatus,
|
||||||
|
startOnboardingScan,
|
||||||
|
} from "./onboarding-scan.js";
|
||||||
|
|
||||||
const owner = { userId: "user-owner", isDeploymentOwner: true };
|
const owner = { userId: "user-owner", spaceId: "space-owner", isDeploymentOwner: true };
|
||||||
const member = { userId: "user-member", isDeploymentOwner: false };
|
const member = { userId: "user-member", spaceId: "space-member", isDeploymentOwner: false };
|
||||||
|
const roots: string[] = [];
|
||||||
|
|
||||||
async function scanHomeWithOneFile(): Promise<string> {
|
/** Fails the test if the scan writes anything before its checks pass. */
|
||||||
const home = await mkdtemp(join(tmpdir(), "evoo-scan-"));
|
const untouchedPrisma = new Proxy(
|
||||||
await mkdir(join(home, "Projects"));
|
{},
|
||||||
await writeFile(join(home, "Projects", "notes.ts"), "export const x = 1;\n");
|
{
|
||||||
return home;
|
get() {
|
||||||
|
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 = await getScanStatus(scanId, userId);
|
const status = onboardingScanStatus(scanId, userId);
|
||||||
if (status.status !== "complete" && status.status !== "failed") throw new Error("pending");
|
if (status.status === "scanning") throw new Error("pending");
|
||||||
return status;
|
return status;
|
||||||
},
|
},
|
||||||
{ timeout: 10_000, interval: 50 },
|
{ timeout: 10_000, interval: 20 },
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
await Promise.all(roots.map((root) => rm(root, { recursive: true, force: true })));
|
||||||
|
});
|
||||||
|
|
||||||
describe("onboarding scan", () => {
|
describe("onboarding scan", () => {
|
||||||
afterEach(() => {
|
it("lists git projects with their languages and tools and never follows a symlink", async () => {
|
||||||
vi.unstubAllEnvs();
|
const home = await tempDir();
|
||||||
|
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("hides a scan from every account except the one that started it", async () => {
|
it("offers a scan folder only to the deployment owner and only when it exists", async () => {
|
||||||
const { scanId } = await startOnboardingScan({ actor: member }, {});
|
const home = await tempDir();
|
||||||
await expect(getScanStatus(scanId, owner.userId)).rejects.toThrow(/Scan not found/);
|
await files(home, ["file.txt"]);
|
||||||
await expect(getScanStatus(scanId, member.userId)).resolves.toMatchObject({
|
await expect(onboardingScanFolder(member, home)).resolves.toBeNull();
|
||||||
status: expect.any(String),
|
await expect(onboardingScanFolder(owner, undefined)).resolves.toBeNull();
|
||||||
});
|
await expect(onboardingScanFolder(owner, path.join(home, "missing"))).resolves.toBeNull();
|
||||||
await expect(getScanStatus("scan_missing", member.userId)).rejects.toThrow(/Scan not found/);
|
await expect(onboardingScanFolder(owner, path.join(home, "file.txt"))).resolves.toBeNull();
|
||||||
|
await expect(onboardingScanFolder(owner, home)).resolves.toBe(home);
|
||||||
|
await expect(startOnboardingScan(member, home)).rejects.toMatchObject({ code: "FORBIDDEN" });
|
||||||
});
|
});
|
||||||
|
|
||||||
it("never reads the host without a configured scan home", async () => {
|
it("keeps a scan with the account that started it and returns only relative paths", async () => {
|
||||||
const { scanId } = await startOnboardingScan({ actor: owner }, {});
|
const home = await tempDir();
|
||||||
|
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.status).toBe("complete");
|
expect(status).toEqual({
|
||||||
expect(status.result).toMatchObject({ localRepos: [], fileSummary: { totalFiles: 0 } });
|
status: "complete",
|
||||||
|
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("lets only the deployment owner discover the configured scan home", async () => {
|
it("checks ownership, the folder and every chosen project again before writing", async () => {
|
||||||
// The shared test setup pins the emulated runtime, which also keeps the scan simulated.
|
const home = await tempDir();
|
||||||
vi.stubEnv("AGENT_RUNTIME", "");
|
const outside = await tempDir();
|
||||||
vi.stubEnv("SANDBOX_PROVIDER", "");
|
await project(home, "code/atlas", ["index.ts"]);
|
||||||
const scanHome = await scanHomeWithOneFile();
|
await project(home, "code/garden", ["main.py"]);
|
||||||
|
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"] };
|
||||||
|
|
||||||
const asMember = await startOnboardingScan({ actor: member, scanHome }, {});
|
await expect(
|
||||||
const memberStatus = await completed(asMember.scanId, member.userId);
|
applyOnboardingScan(untouchedPrisma, { ...owner, isDeploymentOwner: false }, home, choice),
|
||||||
expect(memberStatus.result).toMatchObject({ fileSummary: { totalFiles: 0 } });
|
).rejects.toMatchObject({ code: "FORBIDDEN" });
|
||||||
|
await expect(
|
||||||
|
applyOnboardingScan(untouchedPrisma, owner, outside, choice),
|
||||||
|
).rejects.toMatchObject({ code: "FORBIDDEN" });
|
||||||
|
|
||||||
const asOwner = await startOnboardingScan({ actor: owner, scanHome }, {});
|
// The chosen project became a symlink out of the scan folder after the scan.
|
||||||
const ownerStatus = await completed(asOwner.scanId, owner.userId);
|
await rm(path.join(home, "code/garden"), { recursive: true });
|
||||||
expect(ownerStatus.status).toBe("complete");
|
await symlink(path.join(outside, "elsewhere"), path.join(home, "code/garden"));
|
||||||
expect(ownerStatus.result).toMatchObject({ fileSummary: { totalFiles: 1 } });
|
await expect(applyOnboardingScan(untouchedPrisma, owner, home, choice)).rejects.toMatchObject({
|
||||||
|
code: "BAD_REQUEST",
|
||||||
|
message: "That folder does not exist or is not readable.",
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@ -1,6 +1,7 @@
|
|||||||
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) => ({
|
||||||
@ -13,16 +14,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: {
|
message: { findMany: pending, update: vi.fn() },
|
||||||
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() },
|
||||||
@ -39,17 +40,16 @@ 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", "day");
|
await chooseFocus(deps, actor, "bot", { optionId: "day" });
|
||||||
expect(posted.flatMap((message) => message.blocks)).not.toContainEqual(
|
// The answered card is all that changes; nothing is said around it.
|
||||||
expect.objectContaining({ kind: "app_connect" }),
|
expect(posted).toEqual([]);
|
||||||
);
|
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", "day");
|
await chooseFocus(deps, actor, "bot", { optionId: "day" });
|
||||||
expect(
|
expect(
|
||||||
posted
|
posted
|
||||||
.flatMap((message) => message.blocks)
|
.flatMap((message) => message.blocks)
|
||||||
@ -57,6 +57,8 @@ 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,67 +1,47 @@
|
|||||||
|
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 type { Actor, MessageBlock } from "@rakazo/contracts";
|
import { getCoachRhythm } from "@rakazo/adapters";
|
||||||
|
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";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* First-run conversational onboarding, seeded deterministically into the bot's
|
* Conversational onboarding, posted deterministically into a bot's thread; no model tokens are
|
||||||
* thread: greeting, a focus choice, and available app cards the user authorizes
|
* spent. The main bot asks the onboarding questions (`onboarding-intake.ts`) in the main
|
||||||
* inline. Focus must not rename the bot. No model tokens are spent. The main bot
|
* conversation, one card after each answer. Any other bot gets one focus card that suggests
|
||||||
* talks in the main conversation, where onboarding lands.
|
* apps the user authorizes inline. Focus never renames a bot.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
type OnboardingDeps = {
|
type OnboardingDeps = {
|
||||||
prisma: PrismaClient;
|
prisma: PrismaClient;
|
||||||
events: ThreadEvents;
|
events: ThreadEvents;
|
||||||
connectors: ConnectorRegistry;
|
connectors: ConnectorRegistry;
|
||||||
|
jobs: JobPublisher;
|
||||||
};
|
};
|
||||||
|
|
||||||
type FocusOption = {
|
type Target = { spaceId: string; botId: string; threadId: string };
|
||||||
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.",
|
||||||
@ -79,44 +59,46 @@ 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);
|
const conversation = await ensureMainConversation(deps.prisma, actor.userId, bot.spaceId);
|
||||||
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(
|
/**
|
||||||
deps: OnboardingDeps,
|
* Serializes card writes on a thread. NO KEY UPDATE still lets an answer's effects insert rows
|
||||||
target: { spaceId: string; botId: string; threadId: string },
|
* that reference the thread from another connection (the coach routines do).
|
||||||
|
*/
|
||||||
|
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<string> {
|
): Promise<{ seq: number }> {
|
||||||
const committed = await deps.prisma.$transaction(async (tx: Prisma.TransactionClient) => {
|
|
||||||
const message = await createThreadMessageInTransaction(tx, {
|
const message = await createThreadMessageInTransaction(tx, {
|
||||||
threadId: target.threadId,
|
threadId: target.threadId,
|
||||||
role: "bot",
|
role: "bot",
|
||||||
blocks,
|
blocks,
|
||||||
botId: target.botId,
|
botId: target.botId,
|
||||||
});
|
});
|
||||||
const event = await appendEventInTransaction(tx, {
|
return appendEventInTransaction(tx, {
|
||||||
spaceId: target.spaceId,
|
spaceId: target.spaceId,
|
||||||
threadId: target.threadId,
|
threadId: target.threadId,
|
||||||
botId: target.botId,
|
botId: target.botId,
|
||||||
type: "thread.message.created",
|
type: "thread.message.created",
|
||||||
payload: { messageId: message.id, role: "bot", blocks },
|
payload: { messageId: message.id, role: "bot", blocks },
|
||||||
});
|
});
|
||||||
return { message, event };
|
|
||||||
});
|
|
||||||
await deps.events.notify(target.threadId, committed.event.seq);
|
|
||||||
return committed.message.id;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function updateBlocks(
|
async function updateBlocksInTransaction(
|
||||||
deps: OnboardingDeps,
|
tx: Prisma.TransactionClient,
|
||||||
target: { spaceId: string; botId: string; threadId: string },
|
target: Target,
|
||||||
messageId: string,
|
messageId: string,
|
||||||
blocks: MessageBlock[],
|
blocks: MessageBlock[],
|
||||||
): Promise<void> {
|
): Promise<{ seq: number }> {
|
||||||
const event = await deps.prisma.$transaction(async (tx: Prisma.TransactionClient) => {
|
|
||||||
await tx.message.update({ where: { id: messageId }, data: { blocks } });
|
await tx.message.update({ where: { id: messageId }, data: { blocks } });
|
||||||
return appendEventInTransaction(tx, {
|
return appendEventInTransaction(tx, {
|
||||||
spaceId: target.spaceId,
|
spaceId: target.spaceId,
|
||||||
@ -125,34 +107,60 @@ async function updateBlocks(
|
|||||||
type: "thread.message.updated",
|
type: "thread.message.updated",
|
||||||
payload: { messageId, role: "bot", blocks },
|
payload: { messageId, role: "bot", blocks },
|
||||||
});
|
});
|
||||||
});
|
}
|
||||||
|
|
||||||
|
async function updateBlocks(
|
||||||
|
deps: OnboardingDeps,
|
||||||
|
target: Target,
|
||||||
|
messageId: string,
|
||||||
|
blocks: MessageBlock[],
|
||||||
|
): Promise<void> {
|
||||||
|
const event = await deps.prisma.$transaction((tx: Prisma.TransactionClient) =>
|
||||||
|
updateBlocksInTransaction(tx, target, messageId, 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> {
|
||||||
const { thread } = await requireBotThread(deps, actor, botId);
|
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 messageHasChoice(blocks: MessageBlock[]): boolean {
|
function isChoice(block: MessageBlock): block is ChoiceBlock {
|
||||||
return blocks.some((block) => block.kind === "choice");
|
return block.kind === "choice";
|
||||||
}
|
}
|
||||||
|
|
||||||
function messageHasPendingChoice(blocks: MessageBlock[]): boolean {
|
function pendingChoice(blocks: MessageBlock[]): ChoiceBlock | undefined {
|
||||||
return blocks.some((block) => block.kind === "choice" && !block.answerId);
|
return blocks.find((block): block is ChoiceBlock => isChoice(block) && !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,
|
||||||
@ -160,44 +168,32 @@ 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 };
|
||||||
const blocks: MessageBlock[] = [
|
// The intake runs with the personal main agent; a project's main bot starts like any other.
|
||||||
{
|
const main = await isPersonalMainBot(deps.prisma, bot);
|
||||||
kind: "choice",
|
// Check + insert + event in one transaction so concurrent callers cannot duplicate cards,
|
||||||
question: "What do you want me on first?",
|
// and a concurrent user send cannot publish 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) => {
|
||||||
// Serialize concurrent promptFocus callers on this thread before the gate check.
|
await lockThread(tx, thread.id);
|
||||||
await tx.$executeRaw`SELECT id FROM threads WHERE id = ${thread.id} FOR UPDATE`;
|
if (main && (await tx.userOnboarding.findUnique({ where: { userId: actor.userId } }))) {
|
||||||
|
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 (recent.some((message) => message.role === "user")) return null;
|
if (!main && recent.some((message) => message.role === "user")) return null;
|
||||||
if (recent.some((message) => messageHasChoice(message.blocks as MessageBlock[]))) return null;
|
if (recent.some((message) => (message.blocks as MessageBlock[]).some(isChoice))) return null;
|
||||||
const message = await createThreadMessageInTransaction(tx, {
|
if (!main) return postInTransaction(tx, target, [focusCard()]);
|
||||||
threadId: target.threadId,
|
const first = intakeStep("focus");
|
||||||
role: "bot",
|
await tx.userOnboarding.create({ data: { userId: actor.userId, step: first.id } });
|
||||||
blocks,
|
return postInTransaction(tx, target, [
|
||||||
botId: target.botId,
|
first.card(await loadIntakeContext(tx, actor.userId, new Date())),
|
||||||
|
]);
|
||||||
});
|
});
|
||||||
const event = await appendEventInTransaction(tx, {
|
if (committed) await deps.events.notify(target.threadId, committed.seq);
|
||||||
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) 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,
|
||||||
@ -205,81 +201,128 @@ 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 claimed = await deps.prisma.$transaction(async (tx: Prisma.TransactionClient) => {
|
const pending = await findPendingCard(deps.prisma, thread.id);
|
||||||
await tx.$executeRaw`SELECT id FROM threads WHERE id = ${thread.id} FOR UPDATE`;
|
const skip = pending?.card.step ? secondaryOption(pending.card) : undefined;
|
||||||
const recent = await tx.message.findMany({
|
if (pending?.card.step && skip) {
|
||||||
where: { threadId: thread.id },
|
await chooseFocus(deps, actor, botId, { step: pending.card.step, optionId: skip.id });
|
||||||
orderBy: { createdAt: "asc" },
|
return;
|
||||||
});
|
}
|
||||||
const pending = recent.find((message) =>
|
const event = await deps.prisma.$transaction(async (tx: Prisma.TransactionClient) => {
|
||||||
messageHasPendingChoice(message.blocks as MessageBlock[]),
|
await lockThread(tx, thread.id);
|
||||||
|
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,
|
||||||
);
|
);
|
||||||
if (!pending) return null;
|
return updateBlocksInTransaction(tx, target, current.messageId, blocks);
|
||||||
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,
|
||||||
optionId: string,
|
input: ChooseInput,
|
||||||
): 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 claimed = await deps.prisma.$transaction(async (tx: Prisma.TransactionClient) => {
|
const event = await deps.prisma.$transaction(async (tx: Prisma.TransactionClient) => {
|
||||||
await tx.$executeRaw`SELECT id FROM threads WHERE id = ${thread.id} FOR UPDATE`;
|
await lockThread(tx, thread.id);
|
||||||
const recent = await tx.message.findMany({
|
const current = await findPendingCard(tx, thread.id);
|
||||||
where: { threadId: thread.id },
|
if (current?.messageId !== pending.messageId || current.card.step !== input.step) return null;
|
||||||
orderBy: { createdAt: "asc" },
|
const onboarding = current.card.step
|
||||||
});
|
? await tx.userOnboarding.findUnique({ where: { userId: actor.userId } })
|
||||||
const pending = recent.find((message) =>
|
: null;
|
||||||
messageHasPendingChoice(message.blocks as MessageBlock[]),
|
if (current.card.step) {
|
||||||
|
if (onboarding?.step !== current.card.step) return null;
|
||||||
|
await intakeStep(current.card.step).save(
|
||||||
|
{ prisma: deps.prisma, jobs: deps.jobs, tx, userId: actor.userId },
|
||||||
|
answer,
|
||||||
);
|
);
|
||||||
if (!pending) return null;
|
}
|
||||||
const blocks = (pending.blocks as MessageBlock[]).map((block) =>
|
const answered = answeredCard(current.card, answer);
|
||||||
block.kind === "choice" ? { ...block, answerId: option.id } : block,
|
const blocks = current.blocks.map((block) => (block === current.card ? answered : block));
|
||||||
);
|
let last = await updateBlocksInTransaction(tx, target, current.messageId, blocks);
|
||||||
await tx.message.update({ where: { id: pending.id }, data: { blocks } });
|
for (const message of followUp) last = await postInTransaction(tx, target, message);
|
||||||
const event = await appendEventInTransaction(tx, {
|
if (current.card.step) {
|
||||||
spaceId: target.spaceId,
|
last = await askNext(deps, tx, target, actor.userId, current.card.step, input.canEnablePush);
|
||||||
threadId: target.threadId,
|
}
|
||||||
botId: target.botId,
|
return last;
|
||||||
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
function answeredCard(card: ChoiceBlock, answer: IntakeAnswer): ChoiceBlock {
|
||||||
|
return answer.option
|
||||||
|
? { ...card, answerId: answer.option.id }
|
||||||
|
: { ...card, answerId: CHOICE_TEXT_ANSWER_ID, answerText: answer.text };
|
||||||
|
}
|
||||||
|
|
||||||
|
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 },
|
||||||
});
|
});
|
||||||
if (!claimed) return;
|
return intakeContext(profile, now);
|
||||||
await deps.events.notify(target.threadId, claimed.event.seq);
|
}
|
||||||
|
|
||||||
// Keep the name and title the user chose when creating the bot; the focus
|
/** Posts the next onboarding question, or the closing line once every step is answered. */
|
||||||
// step only suggests apps, it must not rename the bot.
|
async function askNext(
|
||||||
await post(deps, target, [
|
deps: OnboardingDeps,
|
||||||
{
|
tx: Prisma.TransactionClient,
|
||||||
kind: "text",
|
target: Target,
|
||||||
text: `Got it. ${capitalize(option.summary)}. I’ll see what’s already connected so I don’t make you set something up twice.`,
|
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(
|
||||||
@ -290,14 +333,14 @@ export async function chooseFocus(
|
|||||||
traceId: "onboarding.choose",
|
traceId: "onboarding.choose",
|
||||||
spaceId: actor.spaceId,
|
spaceId: actor.spaceId,
|
||||||
userId: actor.userId,
|
userId: actor.userId,
|
||||||
botId: bot.id,
|
botId,
|
||||||
signal: AbortSignal.timeout(15_000),
|
signal: AbortSignal.timeout(15_000),
|
||||||
})
|
})
|
||||||
.catch(() => []),
|
.catch(() => []),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
).flat();
|
).flat();
|
||||||
const cards: MessageBlock[] = option.apps.flatMap((slug) => {
|
const cards = option.apps.flatMap((slug) => {
|
||||||
const entry = catalog.find(
|
const entry = catalog.find(
|
||||||
(item) =>
|
(item) =>
|
||||||
item.slug.toLowerCase() === slug.toLowerCase() ||
|
item.slug.toLowerCase() === slug.toLowerCase() ||
|
||||||
@ -316,27 +359,7 @@ export async function chooseFocus(
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
});
|
});
|
||||||
if (!cards.length) {
|
return cards.length > 0 ? [cards] : [];
|
||||||
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(
|
||||||
@ -376,7 +399,3 @@ 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;
|
|
||||||
}
|
|
||||||
|
|||||||
@ -11,6 +11,7 @@ import type {
|
|||||||
} from "@rakazo/adapter-kit";
|
} from "@rakazo/adapter-kit";
|
||||||
import {
|
import {
|
||||||
computerControlExpireJobKey,
|
computerControlExpireJobKey,
|
||||||
|
MemoryDocumentTooLargeError,
|
||||||
messagingDeliverJob,
|
messagingDeliverJob,
|
||||||
routineJobKey,
|
routineJobKey,
|
||||||
routineWakeupJob,
|
routineWakeupJob,
|
||||||
@ -27,6 +28,7 @@ import type {
|
|||||||
MemoryProviderResolver,
|
MemoryProviderResolver,
|
||||||
PiOAuthLogins,
|
PiOAuthLogins,
|
||||||
RemoteConnectorDependencies,
|
RemoteConnectorDependencies,
|
||||||
|
VapidKeys,
|
||||||
} from "@rakazo/adapters";
|
} from "@rakazo/adapters";
|
||||||
import {
|
import {
|
||||||
acquireComputerExecutionLease,
|
acquireComputerExecutionLease,
|
||||||
@ -43,7 +45,6 @@ import {
|
|||||||
computerSupportsUpdate,
|
computerSupportsUpdate,
|
||||||
computerUpdateView,
|
computerUpdateView,
|
||||||
createVoiceProvider,
|
createVoiceProvider,
|
||||||
deletePushToken,
|
|
||||||
deploymentAutoReviewDefault,
|
deploymentAutoReviewDefault,
|
||||||
destroyBot,
|
destroyBot,
|
||||||
displayBotWorkspacePath,
|
displayBotWorkspacePath,
|
||||||
@ -52,6 +53,7 @@ import {
|
|||||||
filterTeamWorkspaceEntries,
|
filterTeamWorkspaceEntries,
|
||||||
hasActiveComputerControl,
|
hasActiveComputerControl,
|
||||||
isAutoReviewCheckerConfigured,
|
isAutoReviewCheckerConfigured,
|
||||||
|
isBlockedHostname,
|
||||||
isComputerScreenUnavailable,
|
isComputerScreenUnavailable,
|
||||||
isProjectWorkspacePath,
|
isProjectWorkspacePath,
|
||||||
isSandboxGoneError,
|
isSandboxGoneError,
|
||||||
@ -74,7 +76,6 @@ import {
|
|||||||
resolveAutoReviewChecker,
|
resolveAutoReviewChecker,
|
||||||
resolveBotWorkspacePath,
|
resolveBotWorkspacePath,
|
||||||
sanitizeComposioError,
|
sanitizeComposioError,
|
||||||
savePushToken,
|
|
||||||
scheduleComputerControlExpiry,
|
scheduleComputerControlExpiry,
|
||||||
scheduleComputerSleep,
|
scheduleComputerSleep,
|
||||||
screenLeaseIdForRun,
|
screenLeaseIdForRun,
|
||||||
@ -88,9 +89,18 @@ import {
|
|||||||
verifyMcpInstall,
|
verifyMcpInstall,
|
||||||
} from "@rakazo/adapters";
|
} from "@rakazo/adapters";
|
||||||
import type { Auth } from "@rakazo/auth";
|
import type { Auth } from "@rakazo/auth";
|
||||||
import type { Actor, ComputerStatus, McpServer, Me, SpaceNavigation } from "@rakazo/contracts";
|
import type {
|
||||||
|
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";
|
||||||
@ -123,6 +133,7 @@ import {
|
|||||||
findModelCredential,
|
findModelCredential,
|
||||||
findSpaceModelTier,
|
findSpaceModelTier,
|
||||||
formatMessagingLinkCode,
|
formatMessagingLinkCode,
|
||||||
|
getNotificationSettings,
|
||||||
InvalidProjectWorkspacePathError,
|
InvalidProjectWorkspacePathError,
|
||||||
InvalidSpaceNameError,
|
InvalidSpaceNameError,
|
||||||
IsolationError,
|
IsolationError,
|
||||||
@ -131,19 +142,25 @@ 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";
|
||||||
@ -152,11 +169,13 @@ 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 { 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 {
|
||||||
@ -180,7 +199,12 @@ import {
|
|||||||
promptFocus,
|
promptFocus,
|
||||||
startOnboarding,
|
startOnboarding,
|
||||||
} from "./onboarding.js";
|
} from "./onboarding.js";
|
||||||
import { getScanStatus, startOnboardingScan } from "./onboarding-scan.js";
|
import {
|
||||||
|
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";
|
||||||
@ -285,6 +309,11 @@ 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. */
|
||||||
@ -457,6 +486,8 @@ 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;
|
||||||
@ -490,6 +521,26 @@ 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;
|
||||||
@ -504,9 +555,18 @@ function mapSpaceLifecycleError(error: unknown): unknown {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function createRouter(deps: RouterDeps) {
|
export function createRouter(deps: RouterDeps) {
|
||||||
const os = implement(appContract).$context<{ actor: Actor | null; signal?: AbortSignal }>();
|
const os = implement(appContract).$context<{
|
||||||
|
actor: Actor | null;
|
||||||
|
signal?: AbortSignal;
|
||||||
|
userAgent?: string;
|
||||||
|
}>();
|
||||||
const repos = createRepos(deps.prisma);
|
const repos = createRepos(deps.prisma);
|
||||||
const onboardingDeps = { prisma: deps.prisma, events: deps.events, connectors: deps.connectors };
|
const onboardingDeps = {
|
||||||
|
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);
|
||||||
@ -519,6 +579,7 @@ 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");
|
||||||
@ -558,17 +619,78 @@ 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.prisma, context.actor, input),
|
updateProfile(deps, context.actor, input),
|
||||||
),
|
),
|
||||||
forget: authed.profile.forget.handler(({ context, input }) =>
|
forget: authed.profile.forget.handler(({ context, input }) =>
|
||||||
forgetProfile(deps.prisma, context.actor, input),
|
forgetProfile(deps, 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, {
|
||||||
@ -582,20 +704,16 @@ export function createRouter(deps: RouterDeps) {
|
|||||||
}
|
}
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
let workspace = null;
|
const workspace = workspacePath
|
||||||
if (input.workspacePath) {
|
? await wireProjectWorkspace(deps.prisma, { spaceId: space.id, workspacePath })
|
||||||
try {
|
: null;
|
||||||
workspace = await wireProjectWorkspace(deps.prisma, {
|
// A project starts as a conversation with its own main agent.
|
||||||
spaceId: space.id,
|
await ensureMainConversation(deps.prisma, context.actor.userId, space.id);
|
||||||
workspacePath: input.workspacePath,
|
const spaceActor = await requireMembership(deps.prisma, context.actor.userId, space.id);
|
||||||
});
|
const [bots, groups] = await Promise.all([
|
||||||
} catch (error) {
|
repos.listBots(spaceActor),
|
||||||
if (error instanceof InvalidProjectWorkspacePathError) {
|
groupRepos.listGroups(spaceActor),
|
||||||
throw new ORPCError("BAD_REQUEST", { message: error.message });
|
]);
|
||||||
}
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return {
|
return {
|
||||||
id: space.id,
|
id: space.id,
|
||||||
name: space.name,
|
name: space.name,
|
||||||
@ -603,12 +721,26 @@ export function createRouter(deps: RouterDeps) {
|
|||||||
hasContent: false,
|
hasContent: false,
|
||||||
canDelete: true,
|
canDelete: true,
|
||||||
workspace,
|
workspace,
|
||||||
bots: [],
|
bots: bots.map(spaceBotDto),
|
||||||
groups: [],
|
groups: groups.map(spaceGroupDto),
|
||||||
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;
|
||||||
@ -621,6 +753,41 @@ 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.
|
||||||
@ -1219,7 +1386,11 @@ 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(deps.prisma, context.actor.userId, input.name);
|
await renameMainConversation(
|
||||||
|
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);
|
||||||
@ -1507,19 +1678,7 @@ export function createRouter(deps: RouterDeps) {
|
|||||||
input.groupId,
|
input.groupId,
|
||||||
"The main conversation can't be deleted.",
|
"The main conversation can't be deleted.",
|
||||||
);
|
);
|
||||||
const removed = await groupRepos.removeGroup(context.actor, input.groupId);
|
await removeGroupWithArtifacts(deps, groupRepos, 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 };
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
@ -2230,10 +2389,7 @@ export function createRouter(deps: RouterDeps) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (isProjectWorkspacePath(storedPath)) {
|
if (isProjectWorkspacePath(storedPath)) {
|
||||||
const workspaceRoot = await requireProjectWorkspaceRoot(
|
const workspaceRoot = await requireProjectWorkspaceRoot(deps.prisma, context.actor);
|
||||||
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,
|
||||||
@ -2281,10 +2437,7 @@ export function createRouter(deps: RouterDeps) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (isProjectWorkspacePath(storedPath)) {
|
if (isProjectWorkspacePath(storedPath)) {
|
||||||
const workspaceRoot = await requireProjectWorkspaceRoot(
|
const workspaceRoot = await requireProjectWorkspaceRoot(deps.prisma, context.actor);
|
||||||
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,
|
||||||
});
|
});
|
||||||
@ -2427,7 +2580,8 @@ export function createRouter(deps: RouterDeps) {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
if (!doc) throw new IsolationError();
|
if (!doc) throw new IsolationError();
|
||||||
const updated = await deps.memory.commit(
|
const updated = await deps.memory
|
||||||
|
.commit(
|
||||||
{
|
{
|
||||||
scope: doc.scope as "bot" | "user",
|
scope: doc.scope as "bot" | "user",
|
||||||
botId: doc.botId ?? undefined,
|
botId: doc.botId ?? undefined,
|
||||||
@ -2441,7 +2595,13 @@ export function createRouter(deps: RouterDeps) {
|
|||||||
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",
|
||||||
@ -3417,7 +3577,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.optionId);
|
await chooseFocus(onboardingDeps, context.actor, input.botId, input);
|
||||||
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 }) => {
|
||||||
@ -3434,16 +3594,18 @@ export function createRouter(deps: RouterDeps) {
|
|||||||
);
|
);
|
||||||
return { ok: true as const };
|
return { ok: true as const };
|
||||||
}),
|
}),
|
||||||
scan: authed.onboarding.scan.handler(async ({ context, input }) => {
|
scanTarget: authed.onboarding.scanTarget.handler(async ({ context }) => ({
|
||||||
const { scanId } = await startOnboardingScan(
|
folder: await onboardingScanFolder(context.actor, deps.env.onboardingScanHome),
|
||||||
{ actor: context.actor, botId: input.botId, scanHome: deps.env.onboardingScanHome },
|
})),
|
||||||
deps.prisma,
|
scan: authed.onboarding.scan.handler(({ context }) =>
|
||||||
);
|
startOnboardingScan(context.actor, deps.env.onboardingScanHome),
|
||||||
return { scanId };
|
),
|
||||||
}),
|
scanStatus: authed.onboarding.scanStatus.handler(async ({ context, input }) =>
|
||||||
scanStatus: authed.onboarding.scanStatus.handler(async ({ context, input }) => {
|
onboardingScanStatus(input.scanId, context.actor.userId),
|
||||||
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 }) => {
|
||||||
@ -3692,6 +3854,13 @@ 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;
|
||||||
@ -3997,6 +4166,13 @@ 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,
|
||||||
@ -4751,13 +4927,53 @@ export function createRouter(deps: RouterDeps) {
|
|||||||
},
|
},
|
||||||
notifications: {
|
notifications: {
|
||||||
registerPush: authed.notifications.registerPush.handler(async ({ context, input }) => {
|
registerPush: authed.notifications.registerPush.handler(async ({ context, input }) => {
|
||||||
await savePushToken(deps.dataDir, context.actor.userId, input.token);
|
await savePushSubscription(deps.prisma, context.actor.userId, {
|
||||||
|
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 deletePushToken(deps.dataDir, context.actor.userId);
|
await removePushSubscriptionsOfKind(deps.prisma, context.actor.userId, "expo");
|
||||||
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 }) => ({
|
||||||
@ -4894,10 +5110,13 @@ function mapUpdaterError(error: unknown): never {
|
|||||||
|
|
||||||
async function requireProjectWorkspaceRoot(
|
async function requireProjectWorkspaceRoot(
|
||||||
prisma: Pick<PrismaClient, "space">,
|
prisma: Pick<PrismaClient, "space">,
|
||||||
spaceId: string,
|
actor: Actor,
|
||||||
): 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: spaceId },
|
where: { id: actor.spaceId },
|
||||||
select: { workspacePath: true },
|
select: { workspacePath: true },
|
||||||
});
|
});
|
||||||
if (!space?.workspacePath) {
|
if (!space?.workspacePath) {
|
||||||
@ -4939,6 +5158,11 @@ 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,
|
||||||
@ -4959,13 +5183,14 @@ 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 } },
|
where: { spaceId: { in: spaceIds }, NOT: ownProjectMain },
|
||||||
select: { spaceId: true },
|
select: { spaceId: true },
|
||||||
distinct: ["spaceId"],
|
distinct: ["spaceId"],
|
||||||
}),
|
}),
|
||||||
deps.prisma.chatGroup.findMany({
|
deps.prisma.chatGroup.findMany({
|
||||||
where: { spaceId: { in: spaceIds } },
|
where: { spaceId: { in: spaceIds }, NOT: ownProjectMain },
|
||||||
select: { spaceId: true },
|
select: { spaceId: true },
|
||||||
distinct: ["spaceId"],
|
distinct: ["spaceId"],
|
||||||
}),
|
}),
|
||||||
@ -5009,8 +5234,22 @@ 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),
|
||||||
workspace: projectWorkspaceDto(membership.space),
|
// A linked folder is on the owner's host; other members see neither its path nor remote.
|
||||||
bots: spaceBots.map((bot) => ({
|
workspace: actor.isDeploymentOwner ? projectWorkspaceDto(membership.space) : null,
|
||||||
|
bots: spaceBots.map(spaceBotDto),
|
||||||
|
groups: spaceGroups.map(spaceGroupDto),
|
||||||
|
externalConversations: externalConversations.filter(
|
||||||
|
(conversation) => conversation.spaceId === membership.spaceId,
|
||||||
|
),
|
||||||
|
botSections: sectionsFor(membership.spaceId),
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
main,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function spaceBotDto(bot: SpaceBot): SpaceBot {
|
||||||
|
return {
|
||||||
id: bot.id,
|
id: bot.id,
|
||||||
spaceId: bot.spaceId,
|
spaceId: bot.spaceId,
|
||||||
kind: bot.kind,
|
kind: bot.kind,
|
||||||
@ -5024,8 +5263,11 @@ async function spaceNavigationDto(
|
|||||||
preview: bot.preview,
|
preview: bot.preview,
|
||||||
status: bot.status,
|
status: bot.status,
|
||||||
updatedAt: bot.updatedAt,
|
updatedAt: bot.updatedAt,
|
||||||
})),
|
};
|
||||||
groups: spaceGroups.map((group) => ({
|
}
|
||||||
|
|
||||||
|
function spaceGroupDto(group: SpaceGroup): SpaceGroup {
|
||||||
|
return {
|
||||||
id: group.id,
|
id: group.id,
|
||||||
spaceId: group.spaceId,
|
spaceId: group.spaceId,
|
||||||
kind: group.kind,
|
kind: group.kind,
|
||||||
@ -5034,16 +5276,9 @@ async function spaceNavigationDto(
|
|||||||
sectionId: group.sectionId,
|
sectionId: group.sectionId,
|
||||||
members: group.members,
|
members: group.members,
|
||||||
preview: group.preview,
|
preview: group.preview,
|
||||||
|
...(group.previewBlock ? { previewBlock: group.previewBlock } : {}),
|
||||||
unread: group.unread,
|
unread: group.unread,
|
||||||
updatedAt: group.updatedAt,
|
updatedAt: group.updatedAt,
|
||||||
})),
|
|
||||||
externalConversations: externalConversations.filter(
|
|
||||||
(conversation) => conversation.spaceId === membership.spaceId,
|
|
||||||
),
|
|
||||||
botSections: sectionsFor(membership.spaceId),
|
|
||||||
};
|
|
||||||
}),
|
|
||||||
main,
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -5425,6 +5660,7 @@ 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,
|
||||||
@ -5441,6 +5677,7 @@ 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,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
143
apps/api/src/thread-message-pages.postgres.test.ts
Normal file
143
apps/api/src/thread-message-pages.postgres.test.ts
Normal file
@ -0,0 +1,143 @@
|
|||||||
|
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,98 +43,6 @@ 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 () => [
|
||||||
{
|
{
|
||||||
@ -281,45 +189,6 @@ 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 = [
|
||||||
{
|
{
|
||||||
@ -383,21 +252,13 @@ describe("thread message pages", () => {
|
|||||||
createdAt: new Date("2026-08-16T00:00:01.000Z"),
|
createdAt: new Date("2026-08-16T00:00:01.000Z"),
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
const findMany = vi.fn().mockResolvedValueOnce(receiptRows).mockResolvedValueOnce(olderRows);
|
const queryRaw = vi.fn().mockResolvedValueOnce(receiptRows).mockResolvedValueOnce(olderRows);
|
||||||
const prisma = {
|
const prisma = { $queryRaw: queryRaw } as unknown as PrismaClient;
|
||||||
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(findMany).toHaveBeenCalledTimes(2);
|
expect(queryRaw).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 () => {
|
||||||
@ -419,11 +280,8 @@ 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 findMany = vi.fn(async () => [receipt(3), receipt(2), receipt(1)]);
|
const queryRaw = vi.fn(async () => [receipt(3), receipt(2), receipt(1)]);
|
||||||
const prisma = {
|
const prisma = { $queryRaw: queryRaw } as unknown as PrismaClient;
|
||||||
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);
|
||||||
|
|
||||||
@ -432,10 +290,10 @@ describe("thread message pages", () => {
|
|||||||
"message-receipt-3",
|
"message-receipt-3",
|
||||||
]);
|
]);
|
||||||
expect(page.olderCursor).toBe(2);
|
expect(page.olderCursor).toBe(2);
|
||||||
expect(findMany).toHaveBeenCalledTimes(1);
|
expect(queryRaw).toHaveBeenCalledTimes(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("queries before the cursor and returns an ascending bounded page", async () => {
|
it("queries the bot messages view before the cursor as 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}`,
|
||||||
@ -449,7 +307,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);
|
const page = await loadMessagePage(prisma, "thread-1", 6, 2, undefined, true);
|
||||||
|
|
||||||
expect(findMany).toHaveBeenCalledWith({
|
expect(findMany).toHaveBeenCalledWith({
|
||||||
where: { threadId: "thread-1", seq: { lt: 6 } },
|
where: { threadId: "thread-1", seq: { lt: 6 } },
|
||||||
@ -461,7 +319,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 findMany = vi.fn(async () => [
|
const queryRaw = vi.fn(async () => [
|
||||||
{
|
{
|
||||||
id: "message-0",
|
id: "message-0",
|
||||||
threadId: "thread-1",
|
threadId: "thread-1",
|
||||||
@ -472,7 +330,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 = { message: { findMany } } as unknown as PrismaClient;
|
const prisma = { $queryRaw: queryRaw } as unknown as PrismaClient;
|
||||||
|
|
||||||
const page = await loadMessagePage(prisma, "thread-1", 1, 2);
|
const page = await loadMessagePage(prisma, "thread-1", 1, 2);
|
||||||
|
|
||||||
|
|||||||
@ -1,9 +1,18 @@
|
|||||||
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 { Prisma, PrismaClient } from "@rakazo/db";
|
import type { 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,
|
||||||
@ -47,35 +56,67 @@ export async function loadMessagePage(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let cursor = before;
|
if (includePeerRuns) {
|
||||||
while (true) {
|
|
||||||
const rows = await prisma.message.findMany({
|
const rows = await prisma.message.findMany({
|
||||||
where: {
|
where: {
|
||||||
threadId,
|
threadId,
|
||||||
...(cursor === undefined ? {} : { seq: { lt: cursor } }),
|
...(before === undefined ? {} : { seq: { lt: before } }),
|
||||||
},
|
},
|
||||||
orderBy: { seq: "desc" },
|
orderBy: { seq: "desc" },
|
||||||
take: pageSize + 1,
|
take: pageSize + 1,
|
||||||
});
|
});
|
||||||
const hasOlder = rows.length > pageSize;
|
return toPage(threadId, rows, 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 = visibleRows.some(
|
const hasSubstantive = page.messages.some((message) => !isPeerReceiptBlocks(message.blocks));
|
||||||
(row) => !isPeerReceiptBlocks(row.blocks as MessageBlock[]),
|
if (hasSubstantive || includePeerReceipts || page.olderCursor === null) return page;
|
||||||
);
|
cursor = page.olderCursor;
|
||||||
if (hasSubstantive || includePeerReceipts || !hasOlder || includePeerRuns) {
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type MessageRow = Parameters<typeof toThreadMessage>[0];
|
||||||
|
|
||||||
|
function toPage(threadId: string, rows: MessageRow[], pageSize: number): ThreadMessagePage {
|
||||||
|
const pageRows = rows.slice(0, pageSize).reverse();
|
||||||
return {
|
return {
|
||||||
threadId,
|
threadId,
|
||||||
messages: visibleRows.map(toThreadMessage),
|
messages: pageRows.map(toThreadMessage),
|
||||||
olderCursor: hasOlder ? (pageRows[0]?.seq ?? null) : null,
|
olderCursor: rows.length > pageSize ? (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;
|
* 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(
|
||||||
@ -106,15 +147,8 @@ 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(
|
return blocks.some((block) => PEER_VISIBLE_BLOCK_KINDS.includes(block.kind));
|
||||||
(block) =>
|
|
||||||
block.kind === "bot_message_sent" ||
|
|
||||||
block.kind === "bot_message_received" ||
|
|
||||||
block.kind === "ask" ||
|
|
||||||
block.kind === "text",
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -159,10 +193,8 @@ export function shouldForwardPeerThreadEvent(event: {
|
|||||||
!!block &&
|
!!block &&
|
||||||
typeof block === "object" &&
|
typeof block === "object" &&
|
||||||
"kind" in block &&
|
"kind" in block &&
|
||||||
(block.kind === "bot_message_received" ||
|
typeof block.kind === "string" &&
|
||||||
block.kind === "bot_message_sent" ||
|
PEER_VISIBLE_BLOCK_KINDS.includes(block.kind),
|
||||||
block.kind === "ask" ||
|
|
||||||
block.kind === "text"),
|
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -183,7 +183,7 @@ describe("threadSnapshot", () => {
|
|||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
const tx = {
|
const tx = {
|
||||||
$queryRaw: vi.fn().mockResolvedValue([{ id: "thread-1" }]),
|
$queryRaw: threadQueries(),
|
||||||
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,7 +203,10 @@ describe("threadSnapshot", () => {
|
|||||||
|
|
||||||
const snapshot = await threadSnapshot({ prisma }, target);
|
const snapshot = await threadSnapshot({ prisma }, target);
|
||||||
|
|
||||||
expect(tx.$queryRaw).toHaveBeenCalledOnce();
|
expect(tx.$queryRaw).toHaveBeenCalledWith(
|
||||||
|
expect.arrayContaining([expect.stringContaining("FOR SHARE")]),
|
||||||
|
"thread-1",
|
||||||
|
);
|
||||||
expect(findManyEvents).toHaveBeenCalledWith(
|
expect(findManyEvents).toHaveBeenCalledWith(
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
where: expect.objectContaining({
|
where: expect.objectContaining({
|
||||||
@ -245,7 +248,7 @@ describe("threadSnapshot", () => {
|
|||||||
const findManyEvents = vi.fn();
|
const findManyEvents = vi.fn();
|
||||||
const findFirstRun = botRunFindFirst([run]);
|
const findFirstRun = botRunFindFirst([run]);
|
||||||
const tx = {
|
const tx = {
|
||||||
$queryRaw: vi.fn().mockResolvedValue([{ id: "thread-1" }]),
|
$queryRaw: threadQueries(),
|
||||||
message: { findMany: vi.fn().mockResolvedValue([]) },
|
message: { findMany: vi.fn().mockResolvedValue([]) },
|
||||||
event: {
|
event: {
|
||||||
findFirst: vi.fn().mockResolvedValue(null),
|
findFirst: vi.fn().mockResolvedValue(null),
|
||||||
@ -330,7 +333,7 @@ describe("threadSnapshot", () => {
|
|||||||
prisma: {
|
prisma: {
|
||||||
$transaction: vi.fn(async (callback: (client: unknown) => unknown) =>
|
$transaction: vi.fn(async (callback: (client: unknown) => unknown) =>
|
||||||
callback({
|
callback({
|
||||||
$queryRaw: vi.fn().mockResolvedValue([{ id: "thread-1" }]),
|
$queryRaw: threadQueries(),
|
||||||
message: { findMany: vi.fn().mockResolvedValue([]) },
|
message: { findMany: vi.fn().mockResolvedValue([]) },
|
||||||
event: {
|
event: {
|
||||||
findFirst: vi.fn().mockResolvedValue(null),
|
findFirst: vi.fn().mockResolvedValue(null),
|
||||||
@ -385,7 +388,7 @@ describe("threadSnapshot", () => {
|
|||||||
};
|
};
|
||||||
const findFirstRun = botRunFindFirst([failed, completed]);
|
const findFirstRun = botRunFindFirst([failed, completed]);
|
||||||
const tx = {
|
const tx = {
|
||||||
$queryRaw: vi.fn().mockResolvedValue([{ id: "thread-1" }]),
|
$queryRaw: threadQueries(),
|
||||||
message: { findMany: vi.fn().mockResolvedValue([]) },
|
message: { findMany: vi.fn().mockResolvedValue([]) },
|
||||||
event: {
|
event: {
|
||||||
findFirst: vi.fn().mockResolvedValue(null),
|
findFirst: vi.fn().mockResolvedValue(null),
|
||||||
@ -421,7 +424,7 @@ describe("threadSnapshot", () => {
|
|||||||
const findManyEvents = vi.fn();
|
const findManyEvents = vi.fn();
|
||||||
const findFirstRun = botRunFindFirst([]);
|
const findFirstRun = botRunFindFirst([]);
|
||||||
const tx = {
|
const tx = {
|
||||||
$queryRaw: vi.fn().mockResolvedValue([{ id: "thread-1" }]),
|
$queryRaw: threadQueries(),
|
||||||
message: { findMany: vi.fn().mockResolvedValue([]) },
|
message: { findMany: vi.fn().mockResolvedValue([]) },
|
||||||
event: {
|
event: {
|
||||||
findFirst: vi.fn().mockResolvedValue(null),
|
findFirst: vi.fn().mockResolvedValue(null),
|
||||||
@ -925,9 +928,16 @@ 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: vi.fn().mockResolvedValue([{ id: "thread-1" }]),
|
$queryRaw: threadQueries(),
|
||||||
message: { findMany: vi.fn().mockResolvedValue([]) },
|
message: { findMany: vi.fn().mockResolvedValue([]) },
|
||||||
event: {
|
event: {
|
||||||
findFirst: vi.fn().mockResolvedValue(null),
|
findFirst: vi.fn().mockResolvedValue(null),
|
||||||
|
|||||||
@ -1,96 +0,0 @@
|
|||||||
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 });
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@ -1,79 +0,0 @@
|
|||||||
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,4 +1,6 @@
|
|||||||
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,
|
||||||
@ -29,32 +31,32 @@ export function getProfile(prisma: PrismaClient, actor: Actor): Promise<UserProf
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function updateProfile(
|
export async function updateProfile(
|
||||||
prisma: PrismaClient,
|
deps: CoachRhythmDeps,
|
||||||
actor: Actor,
|
actor: Actor,
|
||||||
input: UpdateUserProfile,
|
input: UpdateUserProfile,
|
||||||
): Promise<UserProfileState> {
|
): Promise<UserProfileState> {
|
||||||
try {
|
try {
|
||||||
await updateUserProfile(prisma, actor.userId, input);
|
await updateUserProfile(deps.prisma, actor.userId, input);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
throw toRpcError(error);
|
throw toRpcError(error);
|
||||||
}
|
}
|
||||||
return getUserProfileState(prisma, actor.userId);
|
if (input.timeZone !== undefined) await syncCoachTimeZone(deps, 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(
|
||||||
prisma: PrismaClient,
|
deps: CoachRhythmDeps,
|
||||||
actor: Actor,
|
actor: Actor,
|
||||||
input: ForgetUserFacts,
|
input: ForgetUserFacts,
|
||||||
): Promise<UserProfileState> {
|
): Promise<UserProfileState> {
|
||||||
await forgetUserFacts(
|
const target = input.factIds
|
||||||
prisma,
|
|
||||||
actor.userId,
|
|
||||||
input.factIds
|
|
||||||
? { factIds: input.factIds }
|
? { factIds: input.factIds }
|
||||||
: input.category
|
: input.category
|
||||||
? { category: input.category }
|
? { category: input.category }
|
||||||
: { all: true },
|
: { all: true as const };
|
||||||
);
|
await forgetUserFacts(deps.prisma, actor.userId, target);
|
||||||
return getUserProfileState(prisma, actor.userId);
|
// Forgetting everything clears the time zone too.
|
||||||
|
if ("all" in target) await syncCoachTimeZone(deps, actor.userId);
|
||||||
|
return getUserProfileState(deps.prisma, actor.userId);
|
||||||
}
|
}
|
||||||
|
|||||||
155
apps/desktop/e2e/browser-preview.spec.ts
Normal file
155
apps/desktop/e2e/browser-preview.spec.ts
Normal file
@ -0,0 +1,155 @@
|
|||||||
|
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,6 +42,7 @@ 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",
|
||||||
|
|||||||
96
apps/desktop/src/browser-preview-policy.test.ts
Normal file
96
apps/desktop/src/browser-preview-policy.test.ts
Normal file
@ -0,0 +1,96 @@
|
|||||||
|
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");
|
||||||
|
});
|
||||||
|
});
|
||||||
76
apps/desktop/src/browser-preview-policy.ts
Normal file
76
apps/desktop/src/browser-preview-policy.ts
Normal file
@ -0,0 +1,76 @@
|
|||||||
|
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})`;
|
||||||
|
}
|
||||||
189
apps/desktop/src/browser-preview.ts
Normal file
189
apps/desktop/src/browser-preview.ts
Normal file
@ -0,0 +1,189 @@
|
|||||||
|
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,6 +21,7 @@ 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 {
|
||||||
@ -1130,6 +1131,35 @@ 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,6 +21,22 @@ 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,6 +32,7 @@ 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",
|
||||||
@ -46,6 +47,18 @@ 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");
|
||||||
@ -58,6 +71,8 @@ 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",
|
||||||
@ -70,6 +85,8 @@ 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",
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -77,6 +94,7 @@ 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,7 +28,11 @@ export default function NewSpace() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
router.dismissAll();
|
router.dismissAll();
|
||||||
router.replace("/");
|
// A new project opens in the conversation with its own main agent.
|
||||||
|
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,10 +17,12 @@ 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,
|
||||||
@ -288,6 +290,8 @@ 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);
|
||||||
@ -860,6 +864,7 @@ 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.
|
||||||
@ -885,6 +890,8 @@ 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" ||
|
||||||
@ -1271,6 +1278,23 @@ 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(
|
||||||
@ -1668,6 +1692,43 @@ 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={{
|
||||||
|
|||||||
@ -1857,6 +1857,32 @@ describe("mobile thread event reduction", () => {
|
|||||||
).toBe("Slack · Alex: Hello from the group");
|
).toBe("Slack · Alex: Hello from the group");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
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">>;
|
Partial<Pick<Bot, "parentBotId" | "spaceId" | "kind">>;
|
||||||
|
|
||||||
export type MobileBotSection = BotSection;
|
export type MobileBotSection = BotSection;
|
||||||
|
|
||||||
@ -790,9 +790,11 @@ 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;
|
||||||
};
|
};
|
||||||
@ -874,6 +876,12 @@ 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") {
|
||||||
@ -896,6 +904,7 @@ type ThreadEvent = {
|
|||||||
type: string;
|
type: string;
|
||||||
seq?: number;
|
seq?: number;
|
||||||
runId?: string;
|
runId?: string;
|
||||||
|
createdAt?: string;
|
||||||
payload?: Record<string, unknown>;
|
payload?: Record<string, unknown>;
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -1074,7 +1083,7 @@ export function applyMobileThreadEvent(
|
|||||||
messages: [...remaining, streaming],
|
messages: [...remaining, streaming],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
if (event.type === "agent.tool.completed") {
|
if (event.type === "agent.tool.completed" || event.type === "thread.computer") {
|
||||||
return { ...prev, cursor: event.seq ?? prev.cursor };
|
return { ...prev, cursor: event.seq ?? prev.cursor };
|
||||||
}
|
}
|
||||||
if (event.type === "thread.subagent") {
|
if (event.type === "thread.subagent") {
|
||||||
|
|||||||
@ -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.119.57.55:3100")).toBeNull();
|
expect(apiBaseWarning("http://100.100.100.100: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,6 +111,29 @@ 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,7 +32,10 @@ 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 = [
|
||||||
...space.bots.map((chat) => ({ type: "bot" as const, bot: chat, ...chat })),
|
// A main bot is reached through its space's main conversation, not listed again.
|
||||||
|
...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[] = [];
|
||||||
|
|||||||
104
apps/web/e2e/adaptive-workspace.spec.ts
Normal file
104
apps/web/e2e/adaptive-workspace.spec.ts
Normal file
@ -0,0 +1,104 @@
|
|||||||
|
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);
|
||||||
|
});
|
||||||
53
apps/web/e2e/agent-cursor.spec.ts
Normal file
53
apps/web/e2e/agent-cursor.spec.ts
Normal file
@ -0,0 +1,53 @@
|
|||||||
|
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,6 +27,8 @@ 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);
|
||||||
|
|||||||
@ -4,6 +4,10 @@ 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")',
|
||||||
|
|||||||
@ -16,11 +16,12 @@ test.use({ viewport: { width: 1280, height: 1000 } });
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Texts in `locator` below WCAG AA against the surface they are painted on. With `inside`, every
|
* Texts in `locator` below WCAG AA against the surface they are painted on. With `inside`, every
|
||||||
* visible text and typed value within it counts, except in disabled controls, which WCAG exempts.
|
* visible text and typed value within it counts, except in disabled controls, which WCAG exempts,
|
||||||
|
* and in elements matching `skip`.
|
||||||
*/
|
*/
|
||||||
function unreadableTexts(locator: Locator, inside: boolean) {
|
function unreadableTexts(locator: Locator, inside: boolean, skip: string | null) {
|
||||||
return locator.evaluate(
|
return locator.evaluate(
|
||||||
(root, { inside, min }) => {
|
(root, { inside, skip, min }) => {
|
||||||
const context = document
|
const context = document
|
||||||
.createElement("canvas")
|
.createElement("canvas")
|
||||||
.getContext("2d", { willReadFrequently: true });
|
.getContext("2d", { willReadFrequently: true });
|
||||||
@ -81,7 +82,11 @@ function unreadableTexts(locator: Locator, inside: boolean) {
|
|||||||
};
|
};
|
||||||
const elements = inside
|
const elements = inside
|
||||||
? [root, ...root.querySelectorAll("*")].filter(
|
? [root, ...root.querySelectorAll("*")].filter(
|
||||||
(element) => ownText(element) && shown(element) && !element.closest(":disabled"),
|
(element) =>
|
||||||
|
ownText(element) &&
|
||||||
|
shown(element) &&
|
||||||
|
!element.closest(":disabled") &&
|
||||||
|
!(skip && element.closest(skip)),
|
||||||
)
|
)
|
||||||
: [root];
|
: [root];
|
||||||
return elements.flatMap((element) => {
|
return elements.flatMap((element) => {
|
||||||
@ -90,14 +95,18 @@ function unreadableTexts(locator: Locator, inside: boolean) {
|
|||||||
return ratio < min ? [`${ratio.toFixed(2)}:1 ${text.slice(0, 48)}`] : [];
|
return ratio < min ? [`${ratio.toFixed(2)}:1 ${text.slice(0, 48)}`] : [];
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
{ inside, min: MIN_CONTRAST },
|
{ inside, skip, min: MIN_CONTRAST },
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function expectReadable(locator: Locator, what: string, { inside = false } = {}) {
|
async function expectReadable(
|
||||||
|
locator: Locator,
|
||||||
|
what: string,
|
||||||
|
{ inside = false, skip = null }: { inside?: boolean; skip?: string | null } = {},
|
||||||
|
) {
|
||||||
await expect(locator).toBeVisible();
|
await expect(locator).toBeVisible();
|
||||||
await expect
|
await expect
|
||||||
.poll(() => unreadableTexts(locator, inside), { message: `${what} is readable` })
|
.poll(() => unreadableTexts(locator, inside, skip), { message: `${what} is readable` })
|
||||||
.toEqual([]);
|
.toEqual([]);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -213,6 +222,8 @@ test("shell surfaces stay readable in light and dark themes", async ({ page }, t
|
|||||||
await composer.fill("Draft a note about owls");
|
await composer.fill("Draft a note about owls");
|
||||||
const sidebar = page.getByTestId("bots-sidebar");
|
const sidebar = page.getByTestId("bots-sidebar");
|
||||||
const sidePanel = page.getByTestId("side-panel");
|
const sidePanel = page.getByTestId("side-panel");
|
||||||
|
await expect(sidebar.locator("[data-main-conversation]")).toBeVisible();
|
||||||
|
await expect(sidebar.getByRole("button", { name: "Researcher, 1:1 conversation" })).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}`);
|
||||||
@ -220,27 +231,9 @@ test("shell surfaces stay readable in light and dark themes", async ({ page }, t
|
|||||||
await expectReadable(page.getByTestId("composer-bar"), `${theme}: the composer`, {
|
await expectReadable(page.getByTestId("composer-bar"), `${theme}: the composer`, {
|
||||||
inside: true,
|
inside: true,
|
||||||
});
|
});
|
||||||
await expectReadable(
|
// The header carries the logo, which WCAG exempts; its create button is checked on its own.
|
||||||
sidebar.getByRole("button", { name: "Heute", exact: true }),
|
await expectReadable(sidebar, `${theme}: the sidebar`, { inside: true, skip: ".app-drag" });
|
||||||
`${theme}: Heute`,
|
await expectReadable(page.getByTestId("create-menu-trigger"), `${theme}: the create button`);
|
||||||
{ inside: true },
|
|
||||||
);
|
|
||||||
await expectReadable(
|
|
||||||
sidebar.locator("[data-main-conversation]"),
|
|
||||||
`${theme}: the main conversation row`,
|
|
||||||
{ inside: true },
|
|
||||||
);
|
|
||||||
await expectReadable(
|
|
||||||
sidebar.getByRole("button", { name: "Researcher, 1:1 conversation" }),
|
|
||||||
`${theme}: the selected bot row`,
|
|
||||||
{ inside: true },
|
|
||||||
);
|
|
||||||
await expectReadable(page.getByTestId("trading-mode-status"), `${theme}: trading status`, {
|
|
||||||
inside: true,
|
|
||||||
});
|
|
||||||
await expectReadable(page.getByTestId("user-menu-trigger"), `${theme}: the user menu`, {
|
|
||||||
inside: true,
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
await openNewBot(page);
|
await openNewBot(page);
|
||||||
@ -259,4 +252,12 @@ test("shell surfaces stay readable in light and dark themes", async ({ page }, t
|
|||||||
await captureScreenshot(page, testInfo, `shell-contrast-${theme}-settings`);
|
await captureScreenshot(page, testInfo, `shell-contrast-${theme}-settings`);
|
||||||
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();
|
||||||
|
const heute = page.getByTestId("heute-brief");
|
||||||
|
await expect(heute).toBeVisible();
|
||||||
|
await inEachTheme(page, async (theme) => {
|
||||||
|
await captureScreenshot(page, testInfo, `shell-contrast-${theme}-heute`);
|
||||||
|
await expectReadable(heute, `${theme}: the Heute brief`, { inside: true });
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -1,98 +0,0 @@
|
|||||||
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();
|
|
||||||
});
|
|
||||||
@ -69,9 +69,10 @@ 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");
|
||||||
await page.getByTitle("Agent computer").click();
|
// A takeover request brings the bot's screen beside the chat on its own.
|
||||||
const sidePanel = page.getByTestId("side-panel");
|
const sidePanel = page.getByTestId("side-panel");
|
||||||
await expect(sidePanel).toHaveCSS("width", "384px");
|
await expect(sidePanel).toHaveAttribute("data-panel", "computer");
|
||||||
|
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,9 +303,11 @@ 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();
|
||||||
const markdownDialog = page.getByRole("dialog", { name: "group-preview.md" });
|
// Wide windows open the file beside the group conversation.
|
||||||
await expect(markdownDialog.getByRole("heading", { name: "Group artifact" })).toBeVisible();
|
const markdownPane = page.getByTestId("artifact-pane");
|
||||||
await markdownDialog.getByRole("button", { name: "Close preview" }).click();
|
await expect(markdownPane.getByRole("heading", { name: "Group artifact" })).toBeVisible();
|
||||||
|
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,8 +88,9 @@ async function visible(locator: Locator, timeout: number) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Walks first-run onboarding, which lands in the main conversation. Unless `mainConversation`
|
* Walks the first-run wizard, which lands in the main conversation with the main bot's first
|
||||||
* is set, it then opens the main bot's own thread, where most specs start.
|
* onboarding question pending (the composer works regardless). Unless `mainConversation` is
|
||||||
|
* 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,
|
||||||
@ -101,13 +102,12 @@ 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|preferences|language|voice|integrations|scan|bot)$/,
|
/^(emoji|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,8 +119,6 @@ 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();
|
||||||
@ -133,8 +131,15 @@ export async function completeOnboarding(
|
|||||||
await page.getByRole("button", { name: "Skip", exact: true }).click();
|
await page.getByRole("button", { name: "Skip", exact: true }).click();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Scan step auto-advances to the main bot, then onboarding lands in the main conversation.
|
// The scan step, when shown, either asks first (skip it) or advances by itself. Onboarding
|
||||||
await page.waitForURL(/\/app\/g\/[^/]+$/, { timeout: 30_000 });
|
// then lands in the main conversation.
|
||||||
|
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);
|
||||||
|
|
||||||
@ -242,6 +247,7 @@ export async function openUserSettings(
|
|||||||
| "memory"
|
| "memory"
|
||||||
| "profile"
|
| "profile"
|
||||||
| "voice"
|
| "voice"
|
||||||
|
| "notifications"
|
||||||
| "usage"
|
| "usage"
|
||||||
| "computer"
|
| "computer"
|
||||||
| "updates",
|
| "updates",
|
||||||
|
|||||||
@ -1,20 +1,136 @@
|
|||||||
|
import type { Page } from "@playwright/test";
|
||||||
import { expect, test } from "@playwright/test";
|
import { expect, test } from "@playwright/test";
|
||||||
import { captureScreenshot, completeOnboarding, signup } from "./helpers";
|
import type { CoachRhythm, CoachToday, Goal, Habit, Routine } from "@rakazo/contracts";
|
||||||
|
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 page.getByRole("button", { name: "Heute", exact: true }).click();
|
await openHeute(page);
|
||||||
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();
|
||||||
await expect(page.getByTestId("heute-mission-control-link")).toBeVisible();
|
// Nothing tracked yet: no coach sections.
|
||||||
|
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();
|
||||||
|
});
|
||||||
|
|||||||
159
apps/web/e2e/in-app-browser.spec.ts
Normal file
159
apps/web/e2e/in-app-browser.spec.ts
Normal file
@ -0,0 +1,159 @@
|
|||||||
|
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,6 +39,75 @@ 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,6 +114,36 @@ 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,7 +75,11 @@ 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 page.goto("/desktop-settings");
|
await expect(async () => {
|
||||||
await expect(page.getByRole("alert")).toContainText("Could not open local settings");
|
await page.goto("/desktop-settings", { waitUntil: "domcontentloaded" });
|
||||||
|
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,7 +19,10 @@ 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();
|
||||||
const mainEntry = sidebar.getByRole("button", { name: "Chief, main conversation" });
|
// Projects list their own lead's main conversation too; this is the pinned home one.
|
||||||
|
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);
|
||||||
@ -78,7 +81,8 @@ 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();
|
||||||
await page.waitForURL(/\/app(?:\/)?$/);
|
// The project opens its own main conversation.
|
||||||
|
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);
|
||||||
|
|||||||
@ -4,7 +4,8 @@ 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);
|
||||||
const send = page.getByRole("button", { name: "Send", exact: true });
|
// An open onboarding card in the transcript has its own Send.
|
||||||
|
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("");
|
||||||
@ -72,14 +73,25 @@ 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 so a plain
|
// Empty start: no Fresh-start greeting. Answer the focus card, then ask something long: a bot
|
||||||
// bot text bubble exists for hover layout checks.
|
// row's rail sits at the row's end, so the measured reply must wrap and fill its row.
|
||||||
await expect(page.getByText("What do you want me on first?", { exact: true })).toBeVisible({
|
await expect(
|
||||||
|
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();
|
||||||
const botText = page.getByText(/Got it\./);
|
await expect(page.locator('[data-testid="choice-card"][data-step="goal"]')).toBeVisible({
|
||||||
await expect(botText).toBeVisible({ timeout: 20_000 });
|
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);
|
||||||
@ -121,7 +133,6 @@ 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);
|
||||||
|
|
||||||
@ -387,12 +398,19 @@ 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(page.getByText("What do you want me on first?", { exact: true })).toBeVisible({
|
await expect(
|
||||||
|
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();
|
||||||
const botText = page.getByText(/Got it\./);
|
// Onboarding writes no text of its own, so ask for a plain reply to act on.
|
||||||
await expect(botText).toBeVisible({ timeout: 20_000 });
|
const composer = page.getByRole("combobox", { name: /^Message/ });
|
||||||
|
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,7 +42,12 @@ 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(page.getByText("What do you want me on first?", { exact: true })).toHaveCount(0);
|
await expect(
|
||||||
|
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();
|
||||||
@ -114,20 +119,30 @@ 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(page.getByText("What do you want me on first?", { exact: true })).toBeVisible();
|
await expect(
|
||||||
|
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(page.getByText("What do you want me on first?", { exact: true })).toHaveCount(0);
|
await expect(
|
||||||
|
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(page.getByText("What do you want me on first?", { exact: true })).toHaveCount(0);
|
await expect(
|
||||||
|
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(page.getByText("What do you want me on first?", { exact: true })).toBeVisible();
|
await expect(
|
||||||
|
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(page.getByText("What do you want me on first?", { exact: true })).toHaveCount(0);
|
await expect(
|
||||||
|
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
|
||||||
@ -141,7 +156,9 @@ 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(page.getByText("What do you want me on first?", { exact: true })).toHaveCount(0);
|
await expect(
|
||||||
|
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) => {
|
||||||
|
|||||||
130
apps/web/e2e/notification-settings.spec.ts
Normal file
130
apps/web/e2e/notification-settings.spec.ts
Normal file
@ -0,0 +1,130 @@
|
|||||||
|
/**
|
||||||
|
* 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,11 +16,16 @@ 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(page.getByText("What do you want me on first?", { exact: true })).toBeVisible();
|
await expect(
|
||||||
|
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");
|
||||||
@ -33,6 +38,8 @@ 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$/ });
|
||||||
@ -45,10 +52,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);
|
||||||
await page
|
// The next onboarding question follows the suggestions.
|
||||||
.getByTestId("transcript")
|
const goal = page.locator('[data-testid="choice-card"][data-step="goal"]');
|
||||||
.getByText("Hit those three and I’ll start pulling the picture.")
|
await expect(goal).toContainText("What’s one thing you want to get done in the next few weeks?");
|
||||||
.scrollIntoViewIfNeeded();
|
await slackCard(page).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" });
|
||||||
|
|||||||
77
apps/web/e2e/onboarding-discovery.spec.ts
Normal file
77
apps/web/e2e/onboarding-discovery.spec.ts
Normal file
@ -0,0 +1,77 @@
|
|||||||
|
/**
|
||||||
|
* 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 },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
@ -17,12 +17,11 @@ test.beforeEach(async ({ page }) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* E2E test for the new emoji-first onboarding flow:
|
* E2E test for the emoji-first onboarding flow:
|
||||||
* 1. Sign up → lands on onboarding
|
* 1. Sign up → lands on onboarding
|
||||||
* 2. Design Oo: pick shape + color → live preview
|
* 2. Design Oo: pick shape + color → live preview
|
||||||
* 3. Tell us about yourself: name, timezone, hobbies, work style, communication
|
* 3. Choose language, skip the voice
|
||||||
* 4. Choose language
|
* 4. Skip integrations → bot created → land in chat, where the main bot asks the rest
|
||||||
* 5. Skip integrations → bot created → land in chat
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
test("emoji-first onboarding: shape + color selection with live preview", async ({
|
test("emoji-first onboarding: shape + color selection with live preview", async ({
|
||||||
@ -50,50 +49,13 @@ test("emoji-first onboarding: shape + color selection with live preview", async
|
|||||||
|
|
||||||
if (testInfo) await captureScreenshot(page, testInfo, "02-shape-color-preview");
|
if (testInfo) await captureScreenshot(page, testInfo, "02-shape-color-preview");
|
||||||
|
|
||||||
// Click Continue → goes to preferences step
|
// Click Continue → goes to the language step
|
||||||
await page
|
await page
|
||||||
.getByRole("button", { name: /Continue/i })
|
.getByRole("button", { name: /Continue/i })
|
||||||
.first()
|
.first()
|
||||||
.click();
|
.click();
|
||||||
|
|
||||||
// Step 2: Tell us about yourself
|
// Step 2: Language selection
|
||||||
await expect(page.getByRole("heading", { name: /Tell us about yourself/i })).toBeVisible();
|
|
||||||
|
|
||||||
if (testInfo) await captureScreenshot(page, testInfo, "03-user-preferences");
|
|
||||||
|
|
||||||
// Fill in name
|
|
||||||
await page.getByPlaceholder(/Your name/i).fill("Alice");
|
|
||||||
|
|
||||||
// Fill in timezone
|
|
||||||
await page.getByLabel(/Timezone/i).fill("Europe/Vienna");
|
|
||||||
|
|
||||||
// Select a hobby
|
|
||||||
const hobbyButtons = page.locator("button[aria-pressed]");
|
|
||||||
if (await hobbyButtons.count()) {
|
|
||||||
await hobbyButtons.first().click(); // toggle first hobby
|
|
||||||
}
|
|
||||||
|
|
||||||
// Select work style
|
|
||||||
const workStyleButtons = page.locator("button[aria-pressed]:visible");
|
|
||||||
if ((await workStyleButtons.count()) >= 2) {
|
|
||||||
await workStyleButtons.nth(0).click();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Select communication style
|
|
||||||
const commButtons = page.locator("button[aria-pressed]:visible");
|
|
||||||
if ((await commButtons.count()) >= 2) {
|
|
||||||
await commButtons.nth(0).click();
|
|
||||||
}
|
|
||||||
|
|
||||||
if (testInfo) await captureScreenshot(page, testInfo, "04-preferences-filled");
|
|
||||||
|
|
||||||
// Click Continue → goes to language step
|
|
||||||
await page
|
|
||||||
.getByRole("button", { name: /Continue/i })
|
|
||||||
.first()
|
|
||||||
.click();
|
|
||||||
|
|
||||||
// Step 3: Language selection
|
|
||||||
await expect(page.getByRole("heading", { name: /Choose your language/i })).toBeVisible();
|
await expect(page.getByRole("heading", { name: /Choose your language/i })).toBeVisible();
|
||||||
|
|
||||||
if (testInfo) await captureScreenshot(page, testInfo, "05-language-step");
|
if (testInfo) await captureScreenshot(page, testInfo, "05-language-step");
|
||||||
@ -135,13 +97,6 @@ test("onboarding: skip language defaults to English", async ({ page }) => {
|
|||||||
.first()
|
.first()
|
||||||
.click();
|
.click();
|
||||||
|
|
||||||
// Preferences step — just continue
|
|
||||||
await expect(page.getByRole("heading", { name: /Tell us about yourself/i })).toBeVisible();
|
|
||||||
await page
|
|
||||||
.getByRole("button", { name: /Continue/i })
|
|
||||||
.first()
|
|
||||||
.click();
|
|
||||||
|
|
||||||
// Language step — click Skip, then skip voice.
|
// Language step — click Skip, then skip voice.
|
||||||
await expect(page.getByRole("heading", { name: /Choose your language/i })).toBeVisible();
|
await expect(page.getByRole("heading", { name: /Choose your language/i })).toBeVisible();
|
||||||
await page.getByRole("button", { name: /Skip/i }).click();
|
await page.getByRole("button", { name: /Skip/i }).click();
|
||||||
|
|||||||
91
apps/web/e2e/onboarding-intake.spec.ts
Normal file
91
apps/web/e2e/onboarding-intake.spec.ts
Normal file
@ -0,0 +1,91 @@
|
|||||||
|
/**
|
||||||
|
* E2E: after the wizard the main bot gets to know the user in the main conversation, one card
|
||||||
|
* after each answer, without ever blocking the composer.
|
||||||
|
*/
|
||||||
|
import type { Page } from "@playwright/test";
|
||||||
|
import { expect, test } from "@playwright/test";
|
||||||
|
import { captureScreenshot, completeOnboarding, rpc, signup } from "./helpers";
|
||||||
|
|
||||||
|
function card(page: Page, step: string) {
|
||||||
|
return page.locator(`[data-testid="choice-card"][data-step="${step}"]`);
|
||||||
|
}
|
||||||
|
|
||||||
|
test("the main bot asks focus, goal, check-ins and quiet hours, then says what is next", async ({
|
||||||
|
page,
|
||||||
|
}, testInfo) => {
|
||||||
|
await signup(page, `intake-${Date.now()}@rakazo.test`, "password12", "Intake Tester");
|
||||||
|
await completeOnboarding(page, undefined, { mainConversation: true });
|
||||||
|
|
||||||
|
await page.getByTestId("focus-choice-day").click();
|
||||||
|
const goal = card(page, "goal");
|
||||||
|
await expect(goal).toContainText("What’s one thing you want to get done in the next few weeks?");
|
||||||
|
|
||||||
|
// Typing instead of answering goes to the main bot, and the card stays answerable.
|
||||||
|
const composer = page.getByRole("combobox", { name: "Message" });
|
||||||
|
await composer.fill("Quick question first");
|
||||||
|
await composer.press("Enter");
|
||||||
|
await expect(page.getByTestId("transcript")).toContainText(/on it|handled/i, {
|
||||||
|
timeout: 60_000,
|
||||||
|
});
|
||||||
|
const goalInput = goal.getByRole("textbox", {
|
||||||
|
name: "What’s one thing you want to get done in the next few weeks?",
|
||||||
|
});
|
||||||
|
await goalInput.fill("Run a 10K");
|
||||||
|
await goal.scrollIntoViewIfNeeded();
|
||||||
|
await page.mouse.move(1, 1);
|
||||||
|
await captureScreenshot(page, testInfo, "onboarding-intake-goal");
|
||||||
|
await goalInput.press("Enter");
|
||||||
|
await expect(goal.getByTestId("choice-answer")).toHaveText("Run a 10K");
|
||||||
|
|
||||||
|
const rhythm = card(page, "rhythm");
|
||||||
|
await expect(rhythm).toContainText("When should I check in with you?");
|
||||||
|
// The sidebar previews the main conversation as the web words it: the pending question.
|
||||||
|
const mainEntry = page.locator("aside").first().locator("[data-main-conversation]");
|
||||||
|
await expect(mainEntry).toContainText("When should I check in with you?");
|
||||||
|
await rhythm.scrollIntoViewIfNeeded();
|
||||||
|
await page.mouse.move(1, 1);
|
||||||
|
await captureScreenshot(page, testInfo, "onboarding-intake-rhythm");
|
||||||
|
await rhythm.getByTestId("rhythm-choice-morning").click();
|
||||||
|
await expect(rhythm.getByTestId("choice-answer")).toContainText("Every morning");
|
||||||
|
|
||||||
|
const quiet = card(page, "quiet_hours");
|
||||||
|
await expect(quiet).toContainText("When should I stay quiet?");
|
||||||
|
await quiet.getByTestId("quiet_hours-choice-22-07").click();
|
||||||
|
|
||||||
|
// Only a browser that can still turn push on is asked about notifications.
|
||||||
|
// The web words the closing note itself, from the time of the first check-in.
|
||||||
|
const closing = page
|
||||||
|
.getByTestId("transcript")
|
||||||
|
.getByText(/^All set\. I’ll check in (today|tomorrow) at 8:00\sAM\.$/);
|
||||||
|
const notifications = card(page, "notifications");
|
||||||
|
await expect(notifications.or(closing).first()).toBeVisible();
|
||||||
|
if (await notifications.isVisible()) {
|
||||||
|
await notifications.getByTestId("notifications-choice-later").click();
|
||||||
|
}
|
||||||
|
await expect(closing).toBeVisible();
|
||||||
|
await expect(mainEntry).toContainText(/All set\. I’ll check in (today|tomorrow) at 8:00\sAM\./);
|
||||||
|
await expect(
|
||||||
|
page.locator('[data-testid="choice-card"]:not(:has([data-testid="choice-answer"]))'),
|
||||||
|
).toHaveCount(0);
|
||||||
|
await closing.scrollIntoViewIfNeeded();
|
||||||
|
await page.mouse.move(1, 1);
|
||||||
|
await captureScreenshot(page, testInfo, "onboarding-intake-done");
|
||||||
|
|
||||||
|
const settings = await rpc<{ quietHours: { start: string; end: string } | null }>(
|
||||||
|
page,
|
||||||
|
"notifications/settings/get",
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
expect(settings.quietHours).toEqual({ start: "22:00", end: "07:00" });
|
||||||
|
const profile = await rpc<{ facts: Array<{ value: string; source: string }> }>(
|
||||||
|
page,
|
||||||
|
"profile/get",
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
expect(profile.facts).toContainEqual(
|
||||||
|
expect.objectContaining({
|
||||||
|
value: "Wants help with day-to-day work first",
|
||||||
|
source: "onboarding",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
@ -37,7 +37,7 @@ test("onboarding shows the look picker when a model is still required", async ({
|
|||||||
await page.waitForURL(/\/onboarding/, { timeout: 20_000 });
|
await page.waitForURL(/\/onboarding/, { timeout: 20_000 });
|
||||||
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|preferences|language|voice|integrations)$/,
|
/^(emoji|language|voice|integrations)$/,
|
||||||
{ timeout: 30_000 },
|
{ timeout: 30_000 },
|
||||||
);
|
);
|
||||||
await expect(page.getByRole("heading", { name: /Design your Oo/i })).toBeVisible({
|
await expect(page.getByRole("heading", { name: /Design your Oo/i })).toBeVisible({
|
||||||
@ -56,7 +56,7 @@ for (const unavailable of ["empty", "failed"] as const) {
|
|||||||
await page.waitForURL(/\/onboarding/, { timeout: 20_000 });
|
await page.waitForURL(/\/onboarding/, { timeout: 20_000 });
|
||||||
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|preferences|language|voice|integrations)$/,
|
/^(emoji|language|voice|integrations)$/,
|
||||||
{ timeout: 30_000 },
|
{ timeout: 30_000 },
|
||||||
);
|
);
|
||||||
await expect(page.getByRole("heading", { name: /Design your Oo/i })).toBeVisible({
|
await expect(page.getByRole("heading", { name: /Design your Oo/i })).toBeVisible({
|
||||||
|
|||||||
@ -77,5 +77,5 @@ test("mobile onboarding: category pick and swipe look previews", async ({ page }
|
|||||||
.getByRole("button", { name: /Continue/i })
|
.getByRole("button", { name: /Continue/i })
|
||||||
.first()
|
.first()
|
||||||
.click();
|
.click();
|
||||||
await expect(page.getByRole("heading", { name: /Tell us about yourself/i })).toBeVisible();
|
await expect(page.getByRole("heading", { name: /Choose your language/i })).toBeVisible();
|
||||||
});
|
});
|
||||||
|
|||||||
@ -50,6 +50,16 @@ test("live page helper preserves identity, masks passwords, and reports partial
|
|||||||
expect(snapshot.title).toBe("Fixture");
|
expect(snapshot.title).toBe("Fixture");
|
||||||
expect(snapshot.tree).toContain("Readable article content");
|
expect(snapshot.tree).toContain("Readable article content");
|
||||||
expect(JSON.stringify(snapshot)).not.toContain("fake-test-password");
|
expect(JSON.stringify(snapshot)).not.toContain("fake-test-password");
|
||||||
|
// Element boxes are screen pixels for the visible bot cursor.
|
||||||
|
expect(snapshot.screen.width).toBeGreaterThan(0);
|
||||||
|
expect(snapshot.screen.height).toBeGreaterThan(0);
|
||||||
|
const saveBox = snapshot.elements.find((element: { name: string }) => element.name === "Save")
|
||||||
|
.box as number[];
|
||||||
|
const saveRect = (await page.locator("#save").boundingBox())!;
|
||||||
|
expect(saveBox[2]).toBe(Math.round(saveRect.width));
|
||||||
|
expect(saveBox[3]).toBe(Math.round(saveRect.height));
|
||||||
|
expect(saveBox[0]).toBeGreaterThanOrEqual(Math.round(saveRect.x));
|
||||||
|
expect(saveBox[1]).toBeGreaterThanOrEqual(Math.round(saveRect.y));
|
||||||
const ref = (snapshot: { elements: { name: string; ref: string }[] }, name: string) =>
|
const ref = (snapshot: { elements: { name: string; ref: string }[] }, name: string) =>
|
||||||
snapshot.elements.find((element) => element.name === name)!.ref;
|
snapshot.elements.find((element) => element.name === name)!.ref;
|
||||||
const result = await command("act", {
|
const result = await command("act", {
|
||||||
|
|||||||
87
apps/web/e2e/project-main-agent.spec.ts
Normal file
87
apps/web/e2e/project-main-agent.spec.ts
Normal file
@ -0,0 +1,87 @@
|
|||||||
|
import { expect, type Locator, test } from "@playwright/test";
|
||||||
|
import { captureScreenshot, completeOnboarding, rpc, signup, waitForChatComposer } from "./helpers";
|
||||||
|
|
||||||
|
function projectSpace(projekte: Locator, name: string) {
|
||||||
|
return projekte.locator('[data-sidebar-group^="project-space:"]').filter({ hasText: name });
|
||||||
|
}
|
||||||
|
|
||||||
|
test("a new project opens straight into the conversation with its own main agent", async ({
|
||||||
|
page,
|
||||||
|
}, testInfo) => {
|
||||||
|
const stamp = Date.now();
|
||||||
|
await signup(page, `project-main-${stamp}@rakazo.test`, "password12", "Project Main");
|
||||||
|
await completeOnboarding(page);
|
||||||
|
const homeSpaceId = await page.evaluate(() => window.localStorage.getItem("rakazo:space-id"));
|
||||||
|
const homeMain = await rpc<{ id: string }>(page, "bots/ensureMain", {});
|
||||||
|
|
||||||
|
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\/g\/[^/]+$/, { timeout: 30_000 });
|
||||||
|
await expect
|
||||||
|
.poll(() => page.evaluate(() => window.localStorage.getItem("rakazo:space-id")))
|
||||||
|
.not.toBe(homeSpaceId);
|
||||||
|
await waitForChatComposer(page);
|
||||||
|
|
||||||
|
// The project is one conversation, led by a main agent of its own.
|
||||||
|
const projectEntry = projectSpace(projekte, "Launch").getByRole("button", {
|
||||||
|
name: "Chief, main conversation",
|
||||||
|
});
|
||||||
|
await expect(projectEntry).toBeVisible({ timeout: 30_000 });
|
||||||
|
await expect(projekte.getByRole("button", { name: "Start", exact: true })).toHaveCount(0);
|
||||||
|
const groupId = new URL(page.url()).pathname.split("/").at(-1)!;
|
||||||
|
const snapshot = await rpc<{ leadBotId?: string }>(page, "threads/get", { groupId });
|
||||||
|
expect(snapshot.leadBotId).toBeTruthy();
|
||||||
|
expect(snapshot.leadBotId).not.toBe(homeMain.id);
|
||||||
|
|
||||||
|
const composer = page.getByRole("combobox", { name: "Message" });
|
||||||
|
await composer.fill("What should we do first?");
|
||||||
|
await composer.press("Enter");
|
||||||
|
const transcript = page.getByTestId("transcript");
|
||||||
|
await expect(transcript).toContainText(/on it|handled/i, { timeout: 60_000 });
|
||||||
|
await captureScreenshot(page, testInfo, "project-main-conversation");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a project's main agent hands work to the project's specialist", async ({
|
||||||
|
page,
|
||||||
|
}, testInfo) => {
|
||||||
|
const stamp = Date.now();
|
||||||
|
await signup(page, `project-delegate-${stamp}@rakazo.test`, "password12", "Project Delegate");
|
||||||
|
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\/g\/[^/]+$/, { timeout: 30_000 });
|
||||||
|
await waitForChatComposer(page);
|
||||||
|
const groupId = new URL(page.url()).pathname.split("/").at(-1)!;
|
||||||
|
|
||||||
|
const designer = await rpc<{ id: string }>(page, "bots/create", {
|
||||||
|
name: "Designer",
|
||||||
|
title: "",
|
||||||
|
description: "",
|
||||||
|
instructions: "",
|
||||||
|
notifyOnFinish: true,
|
||||||
|
});
|
||||||
|
const composer = page.getByRole("combobox", { name: "Message" });
|
||||||
|
await composer.fill("delegate to the bot named Designer: sketch a landing page");
|
||||||
|
await composer.press("Enter");
|
||||||
|
|
||||||
|
const transcript = page.getByTestId("transcript");
|
||||||
|
const card = transcript.getByTestId("delegated-task-card");
|
||||||
|
await expect(card).toBeVisible({ timeout: 60_000 });
|
||||||
|
await expect(card).toContainText("Designer");
|
||||||
|
await expect(card).toHaveAttribute("data-status", "done", { timeout: 60_000 });
|
||||||
|
await expect(
|
||||||
|
transcript.getByText("closing the task: sketch a landing page.").first(),
|
||||||
|
).toBeVisible({ timeout: 60_000 });
|
||||||
|
const snapshot = await rpc<{ members?: Array<{ botId: string }> }>(page, "threads/get", {
|
||||||
|
groupId,
|
||||||
|
});
|
||||||
|
expect(snapshot.members?.map((member) => member.botId)).toContain(designer.id);
|
||||||
|
await captureScreenshot(page, testInfo, "project-main-agent-delegates");
|
||||||
|
});
|
||||||
@ -21,30 +21,21 @@ test("sidebar has expected navigation items and no integrations", async ({ page
|
|||||||
// Actual sidebar structure (verified by snapshot):
|
// Actual sidebar structure (verified by snapshot):
|
||||||
// - button "Activity" (top action)
|
// - button "Activity" (top action)
|
||||||
// - textbox "Search" (search input)
|
// - textbox "Search" (search input)
|
||||||
// - button "Heute" (today)
|
// - button "Today"
|
||||||
// - text "Sprich mit deinen Oo's" (conversations section header)
|
// - text "Talk to your Oos" (conversations section header)
|
||||||
// - status "Not configured" (trading bot URL unset)
|
|
||||||
// - NO "Integration(en)" button or text
|
// - NO "Integration(en)" button or text
|
||||||
|
|
||||||
// Search: it's a textbox, not a button
|
// Search: it's a textbox, not a button
|
||||||
const searchInput = sidebar.locator('input[placeholder="Search"], textbox[name="Search"]');
|
const searchInput = sidebar.locator('input[placeholder="Search"], textbox[name="Search"]');
|
||||||
await expect(searchInput).toBeVisible({ timeout: 5000 });
|
await expect(searchInput).toBeVisible({ timeout: 5000 });
|
||||||
|
|
||||||
// "Heute" (Today)
|
const todayBtn = sidebar.getByRole("button", { name: "Today", exact: true });
|
||||||
const todayBtn = sidebar.getByRole("button", { name: "Heute", exact: true });
|
|
||||||
await expect(todayBtn).toBeVisible({ timeout: 5000 });
|
await expect(todayBtn).toBeVisible({ timeout: 5000 });
|
||||||
|
|
||||||
// "Sprich mit deinen Oo's" is plain text, not a button
|
// "Talk to your Oos" is plain text, not a button
|
||||||
const conversationsText = sidebar.getByText("Sprich mit deinen Oo's");
|
const conversationsText = sidebar.getByText("Talk to your Oos");
|
||||||
await expect(conversationsText).toBeVisible({ timeout: 5000 });
|
await expect(conversationsText).toBeVisible({ timeout: 5000 });
|
||||||
|
|
||||||
// Trading mode comes from the bot API. With no URL configured the sidebar
|
|
||||||
// must not claim Live Trading from a local preference.
|
|
||||||
const tradingStatus = sidebar.getByTestId("trading-mode-status");
|
|
||||||
await expect(tradingStatus).toBeVisible({ timeout: 5000 });
|
|
||||||
await expect(tradingStatus).toContainText("Not configured");
|
|
||||||
await expect(sidebar.getByRole("button", { name: "Live Trading" })).toHaveCount(0);
|
|
||||||
|
|
||||||
// No "Integration(en)" button
|
// No "Integration(en)" button
|
||||||
const integrationBtn = sidebar.getByRole("button", { name: /Integration(en)?/i });
|
const integrationBtn = sidebar.getByRole("button", { name: /Integration(en)?/i });
|
||||||
await expect(integrationBtn).toHaveCount(0, { timeout: 5000 });
|
await expect(integrationBtn).toHaveCount(0, { timeout: 5000 });
|
||||||
|
|||||||
@ -41,8 +41,8 @@ test("app loads without React hook errors", async ({ page }) => {
|
|||||||
bodyText.includes("Sign in") ||
|
bodyText.includes("Sign in") ||
|
||||||
bodyText.includes("Anmelden") ||
|
bodyText.includes("Anmelden") ||
|
||||||
bodyText.includes("EvOo") ||
|
bodyText.includes("EvOo") ||
|
||||||
bodyText.includes("Projekte") ||
|
bodyText.includes("Projects") ||
|
||||||
bodyText.includes("Heute") ||
|
bodyText.includes("Today") ||
|
||||||
bodyText.includes("Team");
|
bodyText.includes("Team");
|
||||||
|
|
||||||
expect(hasContent).toBe(true);
|
expect(hasContent).toBe(true);
|
||||||
|
|||||||
@ -3,6 +3,7 @@ import {
|
|||||||
captureScreenshot,
|
captureScreenshot,
|
||||||
completeOnboarding,
|
completeOnboarding,
|
||||||
openNewSpace,
|
openNewSpace,
|
||||||
|
routePatchRpc,
|
||||||
rpc,
|
rpc,
|
||||||
signup,
|
signup,
|
||||||
unterhaltungenOnly,
|
unterhaltungenOnly,
|
||||||
@ -15,7 +16,7 @@ function projectSpace(projekte: Locator, name: string) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
test("Projekte + opens new project dialog instead of group form", async ({ page }) => {
|
test("Projects + opens new project dialog instead of group form", async ({ page }, testInfo) => {
|
||||||
const stamp = Date.now();
|
const stamp = Date.now();
|
||||||
await signup(page, `proj-create-${stamp}@rakazo.test`, "password12", "Project Create");
|
await signup(page, `proj-create-${stamp}@rakazo.test`, "password12", "Project Create");
|
||||||
await completeOnboarding(page);
|
await completeOnboarding(page);
|
||||||
@ -33,18 +34,47 @@ test("Projekte + opens new project dialog instead of group form", async ({ page
|
|||||||
await dialog.getByLabel("Name").fill("Customer project");
|
await dialog.getByLabel("Name").fill("Customer project");
|
||||||
await dialog.getByRole("button", { name: "Create project", exact: true }).click();
|
await dialog.getByRole("button", { name: "Create project", exact: true }).click();
|
||||||
await expect(page).not.toHaveURL(/\/onboarding/);
|
await expect(page).not.toHaveURL(/\/onboarding/);
|
||||||
await page.waitForURL(/\/app/);
|
// The new project opens its conversation with the project's main agent.
|
||||||
await page.waitForLoadState("networkidle");
|
await page.waitForURL(/\/app\/g\/[^/]+$/, { timeout: 30_000 });
|
||||||
await expect(projekte.getByText("Customer project", { exact: true })).toBeVisible({
|
await expect(projekte.getByText("Customer project", { exact: true })).toBeVisible({
|
||||||
timeout: 30_000,
|
timeout: 30_000,
|
||||||
});
|
});
|
||||||
await expect(unterhaltungenOnly(page).getByText("Customer project", { exact: true })).toHaveCount(
|
await expect(unterhaltungenOnly(page).getByText("Customer project", { exact: true })).toHaveCount(
|
||||||
0,
|
0,
|
||||||
);
|
);
|
||||||
await expect(page.getByTestId("project-start-main")).toBeVisible();
|
await expect(
|
||||||
await expect(page.getByTestId("project-start-main")).toHaveText(/Starten/);
|
projectSpace(projekte, "Customer project").getByRole("button", {
|
||||||
|
name: "Chief, main conversation",
|
||||||
|
}),
|
||||||
|
).toBeVisible();
|
||||||
|
await expect(page.getByTestId("project-start-main")).toHaveCount(0);
|
||||||
|
await captureScreenshot(page, testInfo, "new-project-main-conversation");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
for (const owner of [true, false]) {
|
||||||
|
const role = owner ? "owner" : "member";
|
||||||
|
const title = owner
|
||||||
|
? "new project dialog offers a host folder to the deployment owner"
|
||||||
|
: "new project dialog offers no host folder to a member";
|
||||||
|
test(title, async ({ page }, testInfo) => {
|
||||||
|
await signup(page, `proj-folder-${role}-${Date.now()}@rakazo.test`, "password12", "Folder");
|
||||||
|
await completeOnboarding(page);
|
||||||
|
// Whoever signs up first owns the deployment, which depends on spec order.
|
||||||
|
await routePatchRpc(page, "bootstrap", (json) => ({
|
||||||
|
...json,
|
||||||
|
me: { ...(json.me as Record<string, unknown>), isDeploymentOwner: owner },
|
||||||
|
}));
|
||||||
|
await page.goto("/app");
|
||||||
|
await page.waitForURL(/\/app\/[^/]+$/);
|
||||||
|
|
||||||
|
await page.locator(".sidebar-projekte").getByTestId("create-project-trigger").click();
|
||||||
|
const dialog = page.getByRole("dialog", { name: "New project" });
|
||||||
|
await expect(dialog.getByLabel("Name")).toBeVisible();
|
||||||
|
await expect(dialog.getByLabel("Project folder")).toHaveCount(owner ? 1 : 0);
|
||||||
|
await captureScreenshot(page, testInfo, `new-project-${role}`);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
test("spaces stay invisible by default and chat creation requires approval", async ({
|
test("spaces stay invisible by default and chat creation requires approval", async ({
|
||||||
page,
|
page,
|
||||||
}, testInfo) => {
|
}, testInfo) => {
|
||||||
@ -94,17 +124,18 @@ test("spaces stay invisible by default and chat creation requires approval", asy
|
|||||||
const supportSpaceGroup = await supportSpace.getAttribute("data-sidebar-group");
|
const supportSpaceGroup = await supportSpace.getAttribute("data-sidebar-group");
|
||||||
const supportSpaceId = supportSpaceGroup?.match(/^project-space:([^:]+)/)?.[1];
|
const supportSpaceId = supportSpaceGroup?.match(/^project-space:([^:]+)/)?.[1];
|
||||||
expect(supportSpaceId).toBeTruthy();
|
expect(supportSpaceId).toBeTruthy();
|
||||||
await supportSpace.getByRole("button", { name: "Select Customer support" }).click();
|
// A project created from chat comes with its own main agent, reached through its conversation.
|
||||||
await page.waitForURL(/\/app(?:\/)?$/);
|
await supportSpace.getByRole("button", { name: "Chief, main conversation" }).click();
|
||||||
|
await page.waitForURL(/\/app\/g\/[^/]+$/);
|
||||||
await expect
|
await expect
|
||||||
.poll(() => page.evaluate(() => window.localStorage.getItem("rakazo:space-id")))
|
.poll(() => page.evaluate(() => window.localStorage.getItem("rakazo:space-id")))
|
||||||
.toBe(supportSpaceId);
|
.toBe(supportSpaceId);
|
||||||
|
|
||||||
// The main conversation stays the first entry while another space is open.
|
// The home main conversation stays the first entry while another space is open.
|
||||||
await expect(projekte.getByText("Customer support", { exact: true })).toBeVisible();
|
await expect(projekte.getByText("Customer support", { exact: true })).toBeVisible();
|
||||||
await expect(unterhaltungenOnly(page).getByRole("button", { name: /^Chief/ })).toHaveCount(1);
|
await expect(unterhaltungenOnly(page).getByRole("button", { name: /^Chief/ })).toHaveCount(1);
|
||||||
await expect(projekte.getByRole("button", { name: /^Chief/ })).toHaveCount(0);
|
await expect(projekte.getByRole("button", { name: /^Chief/ })).toHaveCount(1);
|
||||||
await expect(projekte.getByRole("button", { name: "Starten", exact: true })).toBeVisible();
|
await expect(projekte.getByRole("button", { name: "Start", exact: true })).toHaveCount(0);
|
||||||
await captureScreenshot(page, testInfo, "spaces-sidebar");
|
await captureScreenshot(page, testInfo, "spaces-sidebar");
|
||||||
|
|
||||||
await page.evaluate((id) => {
|
await page.evaluate((id) => {
|
||||||
@ -118,7 +149,9 @@ test("spaces stay invisible by default and chat creation requires approval", asy
|
|||||||
await expect(projekte.getByText("Customer support", { exact: true })).toBeVisible();
|
await expect(projekte.getByText("Customer support", { exact: true })).toBeVisible();
|
||||||
});
|
});
|
||||||
|
|
||||||
test("a new space opens empty and can be deleted from its menu", async ({ page }, testInfo) => {
|
test("a new space opens its main conversation and can be deleted from its menu", async ({
|
||||||
|
page,
|
||||||
|
}, testInfo) => {
|
||||||
const stamp = Date.now();
|
const stamp = Date.now();
|
||||||
await signup(page, `spaces-delete-${stamp}@rakazo.test`, "password12", "Space Owner");
|
await signup(page, `spaces-delete-${stamp}@rakazo.test`, "password12", "Space Owner");
|
||||||
await completeOnboarding(page);
|
await completeOnboarding(page);
|
||||||
@ -129,18 +162,20 @@ test("a new space opens empty and can be deleted from its menu", async ({ page }
|
|||||||
await dialog.getByLabel("Name").fill("Temporary");
|
await dialog.getByLabel("Name").fill("Temporary");
|
||||||
await dialog.getByRole("button", { name: "Create space", exact: true }).click();
|
await dialog.getByRole("button", { name: "Create space", exact: true }).click();
|
||||||
|
|
||||||
// Onboarding runs once per user: a new space opens directly, with no bot of its own.
|
// Onboarding runs once per user: a new space opens directly in the conversation with its
|
||||||
await page.waitForURL(/\/app(?:\/)?$/);
|
// own main agent, its only bot.
|
||||||
|
await page.waitForURL(/\/app\/g\/[^/]+$/, { timeout: 30_000 });
|
||||||
const projekte = page.locator(".sidebar-projekte");
|
const projekte = page.locator(".sidebar-projekte");
|
||||||
await expect(projekte.getByText("Temporary", { exact: true })).toBeVisible();
|
await expect(projekte.getByText("Temporary", { exact: true })).toBeVisible();
|
||||||
await expect(unterhaltungenOnly(page).getByRole("button", { name: /^Chief/ })).toHaveCount(1);
|
await expect(unterhaltungenOnly(page).getByRole("button", { name: /^Chief/ })).toHaveCount(1);
|
||||||
await expect(projekte.getByRole("button", { name: /^Chief/ })).toHaveCount(0);
|
await expect(
|
||||||
await expect(projekte.getByRole("button", { name: "Starten", exact: true })).toBeVisible();
|
projectSpace(projekte, "Temporary").getByRole("button", { name: "Chief, main conversation" }),
|
||||||
expect(await rpc<unknown[]>(page, "bots/list", {})).toEqual([]);
|
).toBeVisible();
|
||||||
await captureScreenshot(page, testInfo, "new-space-empty");
|
await expect(projekte.getByRole("button", { name: "Start", exact: true })).toHaveCount(0);
|
||||||
|
expect(await rpc<Array<{ kind: string }>>(page, "bots/list", {})).toEqual([
|
||||||
// The empty space stays selectable instead of trapping the user.
|
expect.objectContaining({ kind: "main" }),
|
||||||
await expect(projekte.getByRole("button", { name: "Select Temporary" }).first()).toBeVisible();
|
]);
|
||||||
|
await captureScreenshot(page, testInfo, "new-space-main-conversation");
|
||||||
|
|
||||||
// Deletion is discoverable through the visible Space actions menu.
|
// Deletion is discoverable through the visible Space actions menu.
|
||||||
await projectSpace(projekte, "Temporary")
|
await projectSpace(projekte, "Temporary")
|
||||||
@ -155,6 +190,7 @@ test("a new space opens empty and can be deleted from its menu", async ({ page }
|
|||||||
await deleteSpaceDialog.getByRole("button", { name: "Delete", exact: true }).click();
|
await deleteSpaceDialog.getByRole("button", { name: "Delete", exact: true }).click();
|
||||||
await expect(sidebar.getByText("Temporary", { exact: true })).toHaveCount(0);
|
await expect(sidebar.getByText("Temporary", { exact: true })).toHaveCount(0);
|
||||||
await expect(page).not.toHaveURL(/\/onboarding/);
|
await expect(page).not.toHaveURL(/\/onboarding/);
|
||||||
|
// The project's main agent went with it.
|
||||||
await expect(sidebar.getByRole("button", { name: /^Chief/ })).toHaveCount(1);
|
await expect(sidebar.getByRole("button", { name: /^Chief/ })).toHaveCount(1);
|
||||||
|
|
||||||
// A stale onboarding URL recovers without duplicating the bot.
|
// A stale onboarding URL recovers without duplicating the bot.
|
||||||
@ -187,7 +223,7 @@ test("deleting the last bot in a space stays in the app after first use", async
|
|||||||
const dialog = page.getByRole("dialog", { name: "New space" });
|
const dialog = page.getByRole("dialog", { name: "New space" });
|
||||||
await dialog.getByLabel("Name").fill("Side");
|
await dialog.getByLabel("Name").fill("Side");
|
||||||
await dialog.getByRole("button", { name: "Create space", exact: true }).click();
|
await dialog.getByRole("button", { name: "Create space", exact: true }).click();
|
||||||
await page.waitForURL(/\/app(?:\/)?$/);
|
await page.waitForURL(/\/app\/g\/[^/]+$/, { timeout: 30_000 });
|
||||||
const projekte = page.locator(".sidebar-projekte");
|
const projekte = page.locator(".sidebar-projekte");
|
||||||
await expect(projekte.getByText("Side", { exact: true })).toBeVisible();
|
await expect(projekte.getByText("Side", { exact: true })).toBeVisible();
|
||||||
|
|
||||||
@ -204,8 +240,11 @@ test("deleting the last bot in a space stays in the app after first use", async
|
|||||||
await expect(page).not.toHaveURL(/\/onboarding/);
|
await expect(page).not.toHaveURL(/\/onboarding/);
|
||||||
await page.goto("/app");
|
await page.goto("/app");
|
||||||
await expect(page).not.toHaveURL(/\/onboarding/);
|
await expect(page).not.toHaveURL(/\/onboarding/);
|
||||||
await expect(projekte.getByRole("button", { name: "Select Side" }).first()).toBeVisible();
|
// The project's main conversation stays, so the project opens there.
|
||||||
await expect(page.getByTestId("project-start-main")).toBeVisible();
|
await page.waitForURL(/\/app\/g\/[^/]+$/, { timeout: 30_000 });
|
||||||
|
await expect(
|
||||||
|
projectSpace(projekte, "Side").getByRole("button", { name: "Chief, main conversation" }),
|
||||||
|
).toBeVisible();
|
||||||
|
|
||||||
await projectSpace(projekte, "Side").getByRole("button", { name: "Actions for Side" }).click();
|
await projectSpace(projekte, "Side").getByRole("button", { name: "Actions for Side" }).click();
|
||||||
await expect(page.getByRole("menuitem", { name: "Delete space" })).toBeVisible();
|
await expect(page.getByRole("menuitem", { name: "Delete space" })).toBeVisible();
|
||||||
|
|||||||
@ -1,16 +0,0 @@
|
|||||||
import { test } from "@playwright/test";
|
|
||||||
|
|
||||||
test.describe("Steve Jobs CTO Agent", () => {
|
|
||||||
test.beforeEach(async () => {
|
|
||||||
// We assume the user has logged in and the bot is seeded
|
|
||||||
// This is a placeholder for the perfect test architecture
|
|
||||||
});
|
|
||||||
|
|
||||||
test("should be visible in the sidebar", async () => {
|
|
||||||
// This is a dummy test to show we started the test architecture for Steve Jobs
|
|
||||||
});
|
|
||||||
|
|
||||||
test("should prefer voice communications (VOICE FIRST)", async () => {
|
|
||||||
// Voice first features
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@ -67,6 +67,9 @@ test("account settings appearance control switches to light mode", async ({ page
|
|||||||
await settings.getByRole("button", { name: "Close user settings" }).click();
|
await settings.getByRole("button", { name: "Close user settings" }).click();
|
||||||
await expect(settings).toBeHidden();
|
await expect(settings).toBeHidden();
|
||||||
await expect(page.locator("html")).toHaveAttribute("data-theme", "light");
|
await expect(page.locator("html")).toHaveAttribute("data-theme", "light");
|
||||||
|
// Light surfaces stay light: no dark panel or white-on-paper text survives the switch.
|
||||||
|
await expect(page.locator("main").first()).toHaveCSS("background-color", "rgb(255, 255, 255)");
|
||||||
|
await expect(page.locator("aside").first()).toHaveCSS("background-color", "rgb(245, 245, 242)");
|
||||||
await captureScreenshot(page, testInfo, "ui-appearance-light-shell");
|
await captureScreenshot(page, testInfo, "ui-appearance-light-shell");
|
||||||
await captureSidebarSearchSelected(page, testInfo, "sidebar-search-selected-light");
|
await captureSidebarSearchSelected(page, testInfo, "sidebar-search-selected-light");
|
||||||
|
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
import { expect, test } from "@playwright/test";
|
import { expect, test } from "@playwright/test";
|
||||||
import { completeOnboarding, openNewBot, signup, unterhaltungenOnly } from "./helpers";
|
import { completeOnboarding, openNewBot, signup, unterhaltungenOnly } from "./helpers";
|
||||||
|
|
||||||
test("Unterhaltungen and Projekte stay separate; + opens bot picker", async ({ page }) => {
|
test("conversations and projects stay separate; + opens bot picker", async ({ page }) => {
|
||||||
const stamp = Date.now();
|
const stamp = Date.now();
|
||||||
await signup(page, `unterhaltungen-${stamp}@rakazo.test`, "password12", "Unterhaltungen UX");
|
await signup(page, `unterhaltungen-${stamp}@rakazo.test`, "password12", "Unterhaltungen UX");
|
||||||
await completeOnboarding(page);
|
await completeOnboarding(page);
|
||||||
@ -11,8 +11,8 @@ test("Unterhaltungen and Projekte stay separate; + opens bot picker", async ({ p
|
|||||||
const unterhaltungen = page.locator(".sidebar-unterhaltungen");
|
const unterhaltungen = page.locator(".sidebar-unterhaltungen");
|
||||||
const projekte = page.locator(".sidebar-projekte");
|
const projekte = page.locator(".sidebar-projekte");
|
||||||
|
|
||||||
await expect(unterhaltungen.getByText("Sprich mit deinen Oo's", { exact: true })).toBeVisible();
|
await expect(unterhaltungen.getByText("Talk to your Oos", { exact: true })).toBeVisible();
|
||||||
await expect(projekte.getByText("Projekte", { exact: true })).toBeVisible();
|
await expect(projekte.getByText("Projects", { exact: true })).toBeVisible();
|
||||||
await expect(unterhaltungen.getByRole("button", { name: /^Chief/ })).toBeVisible();
|
await expect(unterhaltungen.getByRole("button", { name: /^Chief/ })).toBeVisible();
|
||||||
|
|
||||||
await unterhaltungen.getByTestId("conversation-picker-trigger").click();
|
await unterhaltungen.getByTestId("conversation-picker-trigger").click();
|
||||||
|
|||||||
74
apps/web/e2e/user-profile-edit.spec.ts
Normal file
74
apps/web/e2e/user-profile-edit.spec.ts
Normal file
@ -0,0 +1,74 @@
|
|||||||
|
/**
|
||||||
|
* E2E: Settings → About you edits a fact in place: its wording, category and the spaces it
|
||||||
|
* shows in.
|
||||||
|
*/
|
||||||
|
import { expect, test } from "@playwright/test";
|
||||||
|
import type { UserProfileState } from "@rakazo/contracts";
|
||||||
|
import { captureScreenshot, completeOnboarding, openUserSettings, rpc, signup } from "./helpers";
|
||||||
|
|
||||||
|
test("a fact's wording, category and spaces are edited in place", async ({ page }, testInfo) => {
|
||||||
|
const stamp = Date.now();
|
||||||
|
await signup(page, `profile-edit-${stamp}@rakazo.test`, "password12", "Edit Tester");
|
||||||
|
await completeOnboarding(page);
|
||||||
|
const side = await rpc<{ id: string }>(page, "spaces/create", { name: "Side project" });
|
||||||
|
await rpc(page, "profile/update", {
|
||||||
|
addFacts: [{ category: "preferences", value: "Prefers tea" }],
|
||||||
|
});
|
||||||
|
|
||||||
|
const settings = await openUserSettings(page, "profile");
|
||||||
|
const profile = settings.getByTestId("user-profile-settings");
|
||||||
|
const tea = profile.getByTestId("user-fact").filter({ hasText: "Prefers tea" });
|
||||||
|
await tea.getByRole("button", { name: "Edit" }).click();
|
||||||
|
|
||||||
|
const editor = profile.getByTestId("user-fact-editor");
|
||||||
|
const wording = editor.getByRole("textbox", { name: "Fact" });
|
||||||
|
await expect(wording).toBeFocused();
|
||||||
|
await wording.fill("Prefers green tea");
|
||||||
|
await editor.getByRole("combobox", { name: "Category" }).selectOption("habits");
|
||||||
|
await editor.getByRole("button", { name: "All spaces" }).click();
|
||||||
|
await page.getByRole("menuitemcheckbox", { name: "Side project" }).click();
|
||||||
|
await expect(page.getByRole("menuitemcheckbox", { name: "All spaces" })).not.toBeChecked();
|
||||||
|
await page.keyboard.press("Escape");
|
||||||
|
await expect(editor.getByRole("button", { name: "Side project" })).toBeVisible();
|
||||||
|
await captureScreenshot(page, testInfo, "user-profile-edit");
|
||||||
|
|
||||||
|
await editor.getByRole("button", { name: "Save", exact: true }).click();
|
||||||
|
await expect(editor).toHaveCount(0);
|
||||||
|
const edited = profile
|
||||||
|
.getByTestId("user-facts-habits")
|
||||||
|
.getByTestId("user-fact")
|
||||||
|
.filter({ hasText: "Prefers green tea" });
|
||||||
|
await expect(edited).toContainText("Side project");
|
||||||
|
await expect(tea).toHaveCount(0);
|
||||||
|
const teaFact = async () =>
|
||||||
|
(await rpc<UserProfileState>(page, "profile/get", null)).facts.find((fact) =>
|
||||||
|
fact.value.includes("tea"),
|
||||||
|
);
|
||||||
|
expect(await teaFact()).toMatchObject({
|
||||||
|
value: "Prefers green tea",
|
||||||
|
category: "habits",
|
||||||
|
allSpaces: false,
|
||||||
|
spaceIds: [side.id],
|
||||||
|
});
|
||||||
|
|
||||||
|
// Escape leaves the fact as it was; showing it everywhere again clears the limit.
|
||||||
|
await edited.getByRole("button", { name: "Edit" }).click();
|
||||||
|
await editor.getByRole("textbox", { name: "Fact" }).fill("Discarded");
|
||||||
|
await page.keyboard.press("Escape");
|
||||||
|
await expect(editor).toHaveCount(0);
|
||||||
|
await expect(edited).toContainText("Prefers green tea");
|
||||||
|
await expect(settings).toBeVisible();
|
||||||
|
|
||||||
|
await edited.getByRole("button", { name: "Edit" }).click();
|
||||||
|
await editor.getByRole("button", { name: "Side project" }).click();
|
||||||
|
await page.getByRole("menuitemcheckbox", { name: "All spaces" }).click();
|
||||||
|
await page.keyboard.press("Escape");
|
||||||
|
await editor.getByRole("button", { name: "Save", exact: true }).click();
|
||||||
|
await expect(editor).toHaveCount(0);
|
||||||
|
await expect(edited).not.toContainText("Side project");
|
||||||
|
expect(await teaFact()).toMatchObject({
|
||||||
|
value: "Prefers green tea",
|
||||||
|
allSpaces: true,
|
||||||
|
spaceIds: [],
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -1,6 +1,7 @@
|
|||||||
/**
|
/**
|
||||||
* E2E: onboarding answers land in the user profile, and Settings → About you shows every
|
* E2E: onboarding fills the user profile without asking (time zone, locale and language from
|
||||||
* fact with its origin, confirms a pending one, and forgets one or all of them.
|
* the browser) and records the focus answer as a fact; Settings → About you shows every fact
|
||||||
|
* with its origin, confirms a pending one, and forgets one or all of them.
|
||||||
*/
|
*/
|
||||||
import { expect, test } from "@playwright/test";
|
import { expect, test } from "@playwright/test";
|
||||||
import { captureScreenshot, openUserSettings, rpc, signup, waitForChatComposer } from "./helpers";
|
import { captureScreenshot, openUserSettings, rpc, signup, waitForChatComposer } from "./helpers";
|
||||||
@ -8,7 +9,7 @@ import { captureScreenshot, openUserSettings, rpc, signup, waitForChatComposer }
|
|||||||
test.use({ timezoneId: "Europe/Vienna" });
|
test.use({ timezoneId: "Europe/Vienna" });
|
||||||
|
|
||||||
test.beforeEach(async ({ page }) => {
|
test.beforeEach(async ({ page }) => {
|
||||||
// Walk the full emoji-first flow, including the preferences step.
|
// Walk the full emoji-first flow.
|
||||||
await page.route("**/rpc/integrationSetup/get", (route) =>
|
await page.route("**/rpc/integrationSetup/get", (route) =>
|
||||||
route.fulfill({
|
route.fulfill({
|
||||||
json: {
|
json: {
|
||||||
@ -34,27 +35,23 @@ test("onboarding feeds the profile; facts can be confirmed and forgotten", async
|
|||||||
});
|
});
|
||||||
await page.getByRole("button", { name: "Continue", exact: true }).click();
|
await page.getByRole("button", { name: "Continue", exact: true }).click();
|
||||||
|
|
||||||
await expect(page.getByRole("heading", { name: /Tell us about yourself/i })).toBeVisible();
|
|
||||||
await expect(page.getByLabel("Timezone")).toHaveValue("Europe/Vienna");
|
|
||||||
await page.getByPlaceholder("Your name").fill("Pat Example");
|
|
||||||
await page.getByRole("button", { name: "Music", exact: true }).click();
|
|
||||||
await page.getByRole("button", { name: "Deep-dive focused" }).click();
|
|
||||||
await page.getByRole("button", { name: "Direct & concise" }).click();
|
|
||||||
await page.getByRole("button", { name: "Continue", exact: true }).click();
|
|
||||||
|
|
||||||
await expect(page.getByRole("heading", { name: /Choose your language/i })).toBeVisible();
|
await expect(page.getByRole("heading", { name: /Choose your language/i })).toBeVisible();
|
||||||
await page.getByRole("button", { name: "English", exact: true }).click();
|
await page.getByRole("button", { name: "English", exact: true }).click();
|
||||||
await expect(page.getByRole("heading", { name: /Pick a voice/i })).toBeVisible();
|
await expect(page.getByRole("heading", { name: /Pick a voice/i })).toBeVisible();
|
||||||
await page.getByRole("button", { name: "Skip", exact: true }).click();
|
await page.getByRole("button", { name: "Skip", exact: true }).click();
|
||||||
|
|
||||||
await page.waitForURL(/\/app\/[^/]+/, { timeout: 30_000 });
|
await page.waitForURL(/\/app\/g\/[^/]+/, { timeout: 30_000 });
|
||||||
await waitForChatComposer(page);
|
await waitForChatComposer(page);
|
||||||
|
// The main bot asks the rest in the main conversation; the focus answer becomes a fact.
|
||||||
|
await page.getByTestId("focus-choice-day").click();
|
||||||
|
await expect(page.locator('[data-testid="choice-card"][data-step="goal"]')).toBeVisible();
|
||||||
|
|
||||||
// The answers no longer overwrite a bot's instructions.
|
// The answers no longer overwrite a bot's instructions.
|
||||||
const bots = await rpc<Array<{ instructions: string }>>(page, "bots/list", null);
|
const bots = await rpc<Array<{ instructions: string }>>(page, "bots/list", null);
|
||||||
expect(bots.length).toBeGreaterThan(0);
|
expect(bots.length).toBeGreaterThan(0);
|
||||||
for (const bot of bots) expect(bot.instructions).not.toContain("User Context");
|
for (const bot of bots) expect(bot.instructions).not.toContain("User Context");
|
||||||
expect((await rpc<{ name: string }>(page, "me", null)).name).toBe("Pat Example");
|
// The account name is used as is; onboarding no longer asks for it again.
|
||||||
|
expect((await rpc<{ name: string }>(page, "me", null)).name).toBe("Profile Tester");
|
||||||
|
|
||||||
// Sensitive facts from anywhere but the user wait for confirmation.
|
// Sensitive facts from anywhere but the user wait for confirmation.
|
||||||
await rpc(page, "profile/update", {
|
await rpc(page, "profile/update", {
|
||||||
@ -67,14 +64,13 @@ test("onboarding feeds the profile; facts can be confirmed and forgotten", async
|
|||||||
await expect(profile.getByTestId("profile-field-timeZone")).toContainText("Europe/Vienna");
|
await expect(profile.getByTestId("profile-field-timeZone")).toContainText("Europe/Vienna");
|
||||||
await expect(profile.getByTestId("profile-field-language")).toContainText("English");
|
await expect(profile.getByTestId("profile-field-language")).toContainText("English");
|
||||||
await expect(profile.getByTestId("profile-field-locale")).toContainText("United States");
|
await expect(profile.getByTestId("profile-field-locale")).toContainText("United States");
|
||||||
await expect(profile.getByTestId("profile-field-communicationStyle")).toContainText(
|
|
||||||
"Direct and concise",
|
|
||||||
);
|
|
||||||
|
|
||||||
const music = profile.getByTestId("user-fact").filter({ hasText: "Interested in music" });
|
const focus = profile
|
||||||
await expect(music).toContainText("Onboarding");
|
.getByTestId("user-fact")
|
||||||
await expect(profile.getByTestId("user-facts-habits")).toContainText(
|
.filter({ hasText: "Wants help with day-to-day work first" });
|
||||||
"Prefers focused deep dives",
|
await expect(focus).toContainText("Onboarding");
|
||||||
|
await expect(profile.getByTestId("user-facts-goals")).toContainText(
|
||||||
|
"Wants help with day-to-day work first",
|
||||||
);
|
);
|
||||||
|
|
||||||
const allergy = profile.getByTestId("user-fact").filter({ hasText: "Allergic to peanuts" });
|
const allergy = profile.getByTestId("user-fact").filter({ hasText: "Allergic to peanuts" });
|
||||||
@ -84,8 +80,8 @@ test("onboarding feeds the profile; facts can be confirmed and forgotten", async
|
|||||||
await expect(allergy).toHaveAttribute("data-status", "active");
|
await expect(allergy).toHaveAttribute("data-status", "active");
|
||||||
await expect(allergy.getByRole("button", { name: "Confirm" })).toHaveCount(0);
|
await expect(allergy.getByRole("button", { name: "Confirm" })).toHaveCount(0);
|
||||||
|
|
||||||
await music.getByRole("button", { name: "Delete" }).click();
|
await focus.getByRole("button", { name: "Delete" }).click();
|
||||||
await expect(music).toHaveCount(0);
|
await expect(focus).toHaveCount(0);
|
||||||
await captureScreenshot(page, testInfo, "user-profile-settings");
|
await captureScreenshot(page, testInfo, "user-profile-settings");
|
||||||
const afterDelete = await rpc<{ facts: Array<{ value: string; status: string }> }>(
|
const afterDelete = await rpc<{ facts: Array<{ value: string; status: string }> }>(
|
||||||
page,
|
page,
|
||||||
@ -93,7 +89,9 @@ test("onboarding feeds the profile; facts can be confirmed and forgotten", async
|
|||||||
null,
|
null,
|
||||||
);
|
);
|
||||||
expect(afterDelete.facts).toEqual(
|
expect(afterDelete.facts).toEqual(
|
||||||
expect.not.arrayContaining([expect.objectContaining({ value: "Interested in music" })]),
|
expect.not.arrayContaining([
|
||||||
|
expect.objectContaining({ value: "Wants help with day-to-day work first" }),
|
||||||
|
]),
|
||||||
);
|
);
|
||||||
expect(afterDelete.facts).toContainEqual(
|
expect(afterDelete.facts).toContainEqual(
|
||||||
expect.objectContaining({ value: "Allergic to peanuts", status: "active" }),
|
expect.objectContaining({ value: "Allergic to peanuts", status: "active" }),
|
||||||
|
|||||||
@ -9,6 +9,7 @@
|
|||||||
<link rel="icon" href="/favicon.ico" sizes="any" />
|
<link rel="icon" href="/favicon.ico" sizes="any" />
|
||||||
<link rel="icon" href="/favicon.svg" type="image/svg+xml" />
|
<link rel="icon" href="/favicon.svg" type="image/svg+xml" />
|
||||||
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
|
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
|
||||||
|
<link rel="manifest" href="/site.webmanifest" />
|
||||||
<!-- Fonts loaded locally via @fontsource-variable (Inter + Geist) — no external CDN -->
|
<!-- Fonts loaded locally via @fontsource-variable (Inter + Geist) — no external CDN -->
|
||||||
<link rel="stylesheet" href="/evoo-brand.css" />
|
<link rel="stylesheet" href="/evoo-brand.css" />
|
||||||
<title>evOo</title>
|
<title>evOo</title>
|
||||||
@ -26,7 +27,7 @@
|
|||||||
document.documentElement.dataset.theme = theme;
|
document.documentElement.dataset.theme = theme;
|
||||||
document.documentElement.style.colorScheme = theme;
|
document.documentElement.style.colorScheme = theme;
|
||||||
const meta = document.querySelector('meta[name="theme-color"]');
|
const meta = document.querySelector('meta[name="theme-color"]');
|
||||||
if (meta) meta.setAttribute("content", theme === "light" ? "#fafaf8" : "#07080a");
|
if (meta) meta.setAttribute("content", theme === "light" ? "#ffffff" : "#07080a");
|
||||||
})();
|
})();
|
||||||
</script>
|
</script>
|
||||||
<script>
|
<script>
|
||||||
|
|||||||
@ -1,4 +1,28 @@
|
|||||||
|
import type { CatalogFormatter, CatalogType } from "@lingui/conf";
|
||||||
import { defineConfig } from "@lingui/conf";
|
import { defineConfig } from "@lingui/conf";
|
||||||
|
import { formatter } from "@lingui/format-po";
|
||||||
|
|
||||||
|
// File references without line numbers, so catalogs change only when messages change.
|
||||||
|
const po = formatter({ lineNumbers: false });
|
||||||
|
const collator = new Intl.Collator("en-US");
|
||||||
|
|
||||||
|
// Lingui orders entries by source text but leaves ties (explicit ids, equal text) in the order the
|
||||||
|
// files were found, which differs between file systems. Break ties by id so output is stable.
|
||||||
|
function sortCatalog(catalog: CatalogType): CatalogType {
|
||||||
|
return Object.fromEntries(
|
||||||
|
Object.entries(catalog).sort(
|
||||||
|
([aId, a], [bId, b]) =>
|
||||||
|
collator.compare(a.message ?? aId, b.message ?? bId) ||
|
||||||
|
collator.compare(a.context ?? "", b.context ?? "") ||
|
||||||
|
(aId < bId ? -1 : aId > bId ? 1 : 0),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const stablePo: CatalogFormatter = {
|
||||||
|
...po,
|
||||||
|
serialize: (catalog, ctx) => po.serialize(sortCatalog(catalog), ctx),
|
||||||
|
};
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
sourceLocale: "en",
|
sourceLocale: "en",
|
||||||
@ -10,5 +34,6 @@ export default defineConfig({
|
|||||||
exclude: ["**/locales/**", "**/*.test.*"],
|
exclude: ["**/locales/**", "**/*.test.*"],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
format: stablePo,
|
||||||
compileNamespace: "es",
|
compileNamespace: "es",
|
||||||
});
|
});
|
||||||
|
|||||||
@ -36,6 +36,7 @@
|
|||||||
"@lingui/babel-plugin-lingui-macro": "6.6.0",
|
"@lingui/babel-plugin-lingui-macro": "6.6.0",
|
||||||
"@lingui/cli": "6.6.0",
|
"@lingui/cli": "6.6.0",
|
||||||
"@lingui/conf": "6.6.0",
|
"@lingui/conf": "6.6.0",
|
||||||
|
"@lingui/format-po": "6.6.0",
|
||||||
"@lingui/vite-plugin": "6.6.0",
|
"@lingui/vite-plugin": "6.6.0",
|
||||||
"@playwright/test": "1.63.0",
|
"@playwright/test": "1.63.0",
|
||||||
"@rolldown/plugin-babel": "0.2.4",
|
"@rolldown/plugin-babel": "0.2.4",
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "EvOo",
|
"name": "evOo",
|
||||||
"short_name": "EvOo",
|
"short_name": "evOo",
|
||||||
"description": "Deine Oos — immer da, immer wach.",
|
"description": "Deine Oos — immer da, immer wach.",
|
||||||
"start_url": "/",
|
"start_url": "/",
|
||||||
"display": "standalone",
|
"display": "standalone",
|
||||||
|
|||||||
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