Compare commits
55 Commits
claude/ama
...
claude/med
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
265f90da5e | ||
|
|
4ab0d25224 | ||
|
|
738811dbe7 | ||
|
|
9407482903 | ||
|
|
45856b1af9 | ||
|
|
fa3bc601b7 | ||
|
|
044669edfc | ||
|
|
404a1d1782 | ||
|
|
3a5ab6ef44 | ||
|
|
9930d295f2 | ||
|
|
354c59f9e3 | ||
|
|
d7e4cb91d6 | ||
|
|
478a92f1a5 | ||
|
|
b3f20967db | ||
|
|
2f8d7175f6 | ||
|
|
f3fb07e41f | ||
|
|
ca57ebda85 | ||
|
|
54658ee689 | ||
|
|
c69f00e891 | ||
|
|
460eede226 | ||
|
|
63578bb645 | ||
|
|
6fd136e944 | ||
|
|
55df4a6439 | ||
|
|
5f3aa8f3fe | ||
|
|
fb5165bb26 | ||
|
|
7db3a56fe8 | ||
|
|
47988602e2 | ||
|
|
9f4991abe0 | ||
|
|
dbf37f86a8 | ||
|
|
d6dd2bef5e | ||
|
|
ad054a7586 | ||
|
|
f7b0cfb341 | ||
|
|
68b1e63c46 | ||
|
|
e928600c98 | ||
|
|
f395ed9a23 | ||
|
|
dda03bff9e | ||
|
|
77559a6284 | ||
|
|
6135bd8d63 | ||
|
|
0c3e3f3d29 | ||
|
|
ece3638ce7 | ||
|
|
59f4a1dfca | ||
|
|
735322b016 | ||
|
|
c4c5ce3c2d | ||
|
|
4ba7d843b1 | ||
|
|
2c6b5c3d7b | ||
|
|
8449dec6c2 | ||
|
|
a51ba87133 | ||
|
|
0d1939c775 | ||
|
|
d912712668 | ||
|
|
e28c3acf02 | ||
|
|
a15da068d7 | ||
|
|
49431def83 | ||
|
|
3c3961f242 | ||
|
|
2b967df569 | ||
|
|
f8eabee997 |
@ -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
|
||||
SIGNUPS_ENABLED=true
|
||||
SIGNUP_ALLOWLIST=
|
||||
# First-run discovery. Leave unset and the onboarding scan never touches the host filesystem.
|
||||
# Set a home-style directory (its Projects, Documents, src, ... subfolders are scanned) to let the
|
||||
# deployment owner's own onboarding discover local projects. Other accounts never scan the host.
|
||||
# First-run discovery. Leave unset and onboarding never looks at the host filesystem.
|
||||
# Set a folder (for example a home folder) and the deployment owner's onboarding offers to list the
|
||||
# git projects in it, reading file and folder names only. Other accounts never see the step.
|
||||
# ONBOARDING_SCAN_HOME=
|
||||
ENCRYPTION_KEY=replace-with-64-random-hex-characters
|
||||
DATA_DIR=./data
|
||||
@ -166,8 +166,12 @@ SMTP_URL=
|
||||
EMAIL_FROM=
|
||||
# Local development only: capture password-reset email and print its link to the API console.
|
||||
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_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).
|
||||
LOG_LEVEL=info
|
||||
LOG_FORMAT=
|
||||
@ -210,26 +214,6 @@ RAKAZO_UPDATER_IMAGE_TAG=local
|
||||
RAKAZO_UPDATER_URL=
|
||||
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).
|
||||
# Overridden by localStorage key rakazo.uiLocale when the user picks a language in Settings.
|
||||
# VITE_DEFAULT_UI_LOCALE=en
|
||||
|
||||
29
.github/workflows/ci.yml
vendored
29
.github/workflows/ci.yml
vendored
@ -134,6 +134,7 @@ jobs:
|
||||
name: desktop-e2e-screenshots
|
||||
path: apps/desktop/e2e/screenshots
|
||||
if-no-files-found: ignore
|
||||
retention-days: 7
|
||||
|
||||
test:
|
||||
name: Unit tests
|
||||
@ -246,6 +247,7 @@ jobs:
|
||||
publish-mobile-update:
|
||||
name: Publish compatible mobile update
|
||||
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
|
||||
continue-on-error: true
|
||||
needs: [lint, check, build, test, test-integration, test-e2e]
|
||||
runs-on: [self-hosted, evoo-ci, Linux]
|
||||
timeout-minutes: 20
|
||||
@ -344,23 +346,30 @@ jobs:
|
||||
fi
|
||||
echo "publish=$publish" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Require Expo automation token
|
||||
- name: Check Expo automation token
|
||||
id: expo_token
|
||||
if: steps.ota.outputs.publish == 'true'
|
||||
env:
|
||||
EXPO_TOKEN: ${{ secrets.EXPO_TOKEN }}
|
||||
run: |
|
||||
if [[ -z "$EXPO_TOKEN" ]]; then
|
||||
echo "Add an EXPO_TOKEN repository secret with access to the inbox-zero/rakazo Expo project." >&2
|
||||
exit 1
|
||||
{
|
||||
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
|
||||
echo "ready=true" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- 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
|
||||
with:
|
||||
dest: ${{ runner.temp }}/setup-pnpm-${{ github.run_id }}-${{ github.job }}
|
||||
- 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
|
||||
run: |
|
||||
set -euo pipefail
|
||||
@ -377,25 +386,25 @@ jobs:
|
||||
mkdir -p "$store"
|
||||
echo "npm_config_store_dir=${store}" >> "$GITHUB_ENV"
|
||||
- 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:
|
||||
node-version: 24
|
||||
cache: pnpm
|
||||
- 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
|
||||
with:
|
||||
eas-version: latest
|
||||
packager: pnpm
|
||||
token: ${{ secrets.EXPO_TOKEN }}
|
||||
- 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
|
||||
- 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
|
||||
- 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
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
82
.github/workflows/cleanup-actions-artifacts.yml
vendored
Normal file
82
.github/workflows/cleanup-actions-artifacts.yml
vendored
Normal file
@ -0,0 +1,82 @@
|
||||
name: cleanup-actions-artifacts
|
||||
|
||||
# Manual reclaim of Actions artifact storage for this repository.
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
dry_run:
|
||||
description: List artifacts only; do not delete
|
||||
type: boolean
|
||||
default: false
|
||||
push:
|
||||
branches: [main]
|
||||
paths:
|
||||
- .github/workflows/cleanup-actions-artifacts.yml
|
||||
|
||||
permissions:
|
||||
actions: write
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
cleanup:
|
||||
name: Delete stale Actions artifacts
|
||||
runs-on: [self-hosted, evoo-ci, Linux]
|
||||
timeout-minutes: 30
|
||||
steps:
|
||||
- name: Remove old artifacts
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
DRY_RUN: ${{ github.event_name == 'workflow_dispatch' && inputs.dry_run || 'false' }}
|
||||
REPOSITORY: ${{ github.repository }}
|
||||
run: |
|
||||
python3 <<'PY'
|
||||
import json, os, subprocess, sys
|
||||
|
||||
repo = os.environ["REPOSITORY"]
|
||||
dry_run = os.environ.get("DRY_RUN", "false").lower() == "true"
|
||||
|
||||
def gh(*args):
|
||||
return subprocess.check_output(["gh", "api", *args], text=True)
|
||||
|
||||
def should_delete(name: str) -> bool:
|
||||
if name.startswith("playwright-artifacts-"):
|
||||
return True
|
||||
if name.startswith("digest-"):
|
||||
return True
|
||||
if name == "desktop-e2e-screenshots":
|
||||
return True
|
||||
if name.endswith(".dockerbuild"):
|
||||
return True
|
||||
return False
|
||||
|
||||
artifacts = []
|
||||
page = 1
|
||||
while True:
|
||||
data = json.loads(
|
||||
gh(f"repos/{repo}/actions/artifacts?per_page=100&page={page}")
|
||||
)
|
||||
artifacts.extend(data.get("artifacts", []))
|
||||
if len(data.get("artifacts", [])) < 100:
|
||||
break
|
||||
page += 1
|
||||
|
||||
deleted = 0
|
||||
freed = 0
|
||||
for art in artifacts:
|
||||
name = art["name"]
|
||||
if not should_delete(name):
|
||||
print(f"skip id={art['id']} name={name} size={art['size_in_bytes']}")
|
||||
continue
|
||||
if dry_run:
|
||||
print(f"would delete id={art['id']} name={name} size={art['size_in_bytes']}")
|
||||
continue
|
||||
subprocess.check_call(
|
||||
["gh", "api", "--method", "DELETE", f"repos/{repo}/actions/artifacts/{art['id']}"]
|
||||
)
|
||||
print(f"deleted id={art['id']} name={name} size={art['size_in_bytes']}")
|
||||
deleted += 1
|
||||
freed += art["size_in_bytes"]
|
||||
|
||||
print(f"deleted_count={deleted}")
|
||||
print(f"freed_bytes={freed}")
|
||||
PY
|
||||
6
.github/workflows/publish-server-image.yml
vendored
6
.github/workflows/publish-server-image.yml
vendored
@ -27,6 +27,8 @@ permissions:
|
||||
env:
|
||||
# docker/metadata-action otherwise truncates commit tags to seven collision-prone characters.
|
||||
DOCKER_METADATA_SHORT_SHA_LENGTH: 40
|
||||
# build-push-action v6 uploads large *.dockerbuild records by default; publish only needs digest artifacts.
|
||||
DOCKER_BUILD_RECORD_UPLOAD: false
|
||||
|
||||
concurrency:
|
||||
group: publish-server-image-${{ github.ref }}
|
||||
@ -263,8 +265,8 @@ jobs:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
owner_type="$(gh api "repos/${GITHUB_REPOSITORY}" --jq -r '.owner.type')"
|
||||
private="$(gh api "repos/${GITHUB_REPOSITORY}" --jq -r '.private')"
|
||||
owner_type="$(gh api "repos/${GITHUB_REPOSITORY}" --jq '.owner.type')"
|
||||
private="$(gh api "repos/${GITHUB_REPOSITORY}" --jq '.private')"
|
||||
if [[ "$owner_type" == "User" && "$private" == "true" ]]; then
|
||||
echo "enabled=false" >> "$GITHUB_OUTPUT"
|
||||
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
|
||||
playwright-report-www
|
||||
*.pid
|
||||
.evoo_server.pid
|
||||
|
||||
# Large voice / model vendor blobs (recreate Kokoro separately)
|
||||
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.
|
||||
- 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.
|
||||
- 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.
|
||||
- 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.
|
||||
|
||||
@ -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` |
|
||||
| Commit | `f00577bb05e3cf68381fafb9cb32929becd1e352` |
|
||||
| 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.
|
||||
|
||||
@ -12,7 +12,7 @@
|
||||
|----|----|
|
||||
| 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 |
|
||||
| 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
|
||||
|
||||
@ -53,10 +53,7 @@ Full local notes: **[docs/LOCAL-SETUP.md](./docs/LOCAL-SETUP.md)**
|
||||
|
||||
## Antigravity tools kept on this branch
|
||||
|
||||
- `scripts/self_improve.py`
|
||||
- `scripts/autonomous-sentinel.ts`
|
||||
- `packages/core/src/self-update.ts`
|
||||
- `orchestrator/` (agent_runner + agents.json)
|
||||
- `apps/desktop/src/local-stack.ts`
|
||||
- `packages/adapters/src/pi-local-provider.ts`
|
||||
- `scripts/seed-ollama.ts`
|
||||
|
||||
@ -60,6 +60,8 @@ One bot has one continuous visible thread because continuity is part of its iden
|
||||
Team Computers share a persisted browser identity and isolate simultaneous live desktops when supported because shared login state and safe concurrency are both required.
|
||||
Private Computers isolate the entire working home because some trust boundaries must be stronger than collaboration convenience.
|
||||
Connections belong to the user or space rather than requiring repetitive per-bot setup because bots differ through role and state, not account plumbing.
|
||||
Bots answer with cards from a fixed catalog of sections rather than generated interface because the backend must validate every card and apply every tap, and a small catalog stays calm and consistent on every surface.
|
||||
An autopilot works on an order with no time or cost limit, but pauses when runs stop reporting progress and always shows when it started and ended, because long unattended work must stay visible and must never spin silently.
|
||||
|
||||
## Scope
|
||||
|
||||
|
||||
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 |
|
||||
| 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.
|
||||
|
||||
|
||||
@ -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()
|
||||
@ -29,6 +29,7 @@ function setup() {
|
||||
},
|
||||
spaceMember: { findUnique: vi.fn(async () => ({ role: "owner" })) },
|
||||
deploymentSettings: { findUnique: vi.fn(async () => null) },
|
||||
integrationProviderConfig: { findUnique: vi.fn(async () => null) },
|
||||
aiDataConsent: { findMany: vi.fn(async () => []), upsert },
|
||||
$transaction: vi.fn(async (queries) => Promise.all(queries)),
|
||||
},
|
||||
@ -134,6 +135,49 @@ describe("consent grants", () => {
|
||||
expect(JSON.stringify(status.recipients)).not.toContain("supermemory");
|
||||
expect(deps.prisma.spaceMemoryConfig.findUnique).not.toHaveBeenCalled();
|
||||
});
|
||||
it("discloses the deployment's media servers before model use, keyed by server", async () => {
|
||||
const { deps, actor } = setup();
|
||||
const settings = {
|
||||
comfyui: { baseUrl: "http://127.0.0.1:8188", apiKey: "comfy-key", imageModel: "sdxl" },
|
||||
transcription: { baseUrl: "http://127.0.0.1:8080/v1", model: "whisper-1" },
|
||||
};
|
||||
vi.mocked(deps.prisma.integrationProviderConfig.findUnique).mockResolvedValue({
|
||||
id: "media",
|
||||
ciphertext: "fake-media-ciphertext",
|
||||
} as never);
|
||||
deps.secrets = {
|
||||
load: vi.fn(() => JSON.stringify(settings)),
|
||||
} as unknown as RouterDeps["secrets"];
|
||||
const status = await aiConsentStatus(deps, actor, { botId: "bot", uses: ["model"] });
|
||||
expect(status.recipients.map((recipient) => recipient.name)).toEqual([
|
||||
"ComfyUI (http://127.0.0.1:8188)",
|
||||
"Transcription (http://127.0.0.1:8080)",
|
||||
]);
|
||||
expect(status.recipients[0]?.detail).toBe("Model: sdxl.");
|
||||
expect(JSON.stringify(status)).not.toContain("comfy-key");
|
||||
expect((await aiConsentStatus(deps, actor, { uses: ["voice"] })).recipients).toHaveLength(1);
|
||||
});
|
||||
it("discloses the transcription server for dictation when the voice provider cannot transcribe", async () => {
|
||||
const { deps, actor } = setup();
|
||||
vi.mocked(deps.prisma.integrationProviderConfig.findUnique).mockResolvedValue({
|
||||
id: "media",
|
||||
ciphertext: "fake-media-ciphertext-voice",
|
||||
} as never);
|
||||
deps.secrets = {
|
||||
load: vi.fn(() =>
|
||||
JSON.stringify({ comfyui: null, transcription: { baseUrl: "http://127.0.0.1:8080/v1" } }),
|
||||
),
|
||||
} as unknown as RouterDeps["secrets"];
|
||||
vi.mocked(deps.prisma.spaceVoicePreference.findFirst).mockResolvedValue({
|
||||
credential: { provider: "kokoro" },
|
||||
voiceId: "af_heart",
|
||||
} as never);
|
||||
const status = await aiConsentStatus(deps, actor, { uses: ["voice"] });
|
||||
expect(status.recipients.map((recipient) => [recipient.name, recipient.use])).toEqual([
|
||||
["Local voice", "voice"],
|
||||
["Transcription (http://127.0.0.1:8080)", "voice"],
|
||||
]);
|
||||
});
|
||||
it("returns an operator policy without requiring a hosted provider", async () => {
|
||||
const { deps, actor } = setup();
|
||||
deps.env.privacyPolicyUrl = "https://example.com/privacy";
|
||||
|
||||
@ -3,9 +3,13 @@ import { ORPCError } from "@orpc/server";
|
||||
import {
|
||||
aiRecipient,
|
||||
cloudAgentsEnabled,
|
||||
loadMediaSettings,
|
||||
MEDIA_COMFYUI_PROVIDER_ID,
|
||||
MEDIA_TRANSCRIPTION_PROVIDER_ID,
|
||||
parseModelSecret,
|
||||
selectConfiguredModel,
|
||||
toStringRecord,
|
||||
voiceCatalogEntry,
|
||||
} from "@rakazo/adapters";
|
||||
import type { Actor, AiConsentQuery, AiConsentStatus, AiRecipient } from "@rakazo/contracts";
|
||||
import { AI_DATA_DISCLOSURES, AI_DISCLOSURE_VERSION, AI_PRIVACY_URL } from "@rakazo/contracts";
|
||||
@ -183,6 +187,21 @@ export async function aiConsentStatus(
|
||||
}
|
||||
for (const voice of voices)
|
||||
add(aiRecipient({ provider: voice.credential.provider, use: "voice" }));
|
||||
if (uses.includes("voice")) {
|
||||
// Dictation goes to the owner's transcription server when the voice provider cannot transcribe.
|
||||
const transcription = (await loadMediaSettings(deps)).transcription;
|
||||
const credential = transcription ? await findDefaultVoiceCredential(deps.prisma, actor) : null;
|
||||
if (transcription && !(credential && voiceCatalogEntry(credential.provider)?.transcribe)) {
|
||||
add(
|
||||
aiRecipient({
|
||||
provider: MEDIA_TRANSCRIPTION_PROVIDER_ID,
|
||||
use: "voice",
|
||||
baseUrl: transcription.baseUrl,
|
||||
modelId: transcription.model,
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
if (includesMemory) {
|
||||
if (memoryOwner) {
|
||||
const memory = await deps.prisma.spaceMemoryConfig.findUnique({
|
||||
@ -210,6 +229,30 @@ export async function aiConsentStatus(
|
||||
) {
|
||||
add(aiRecipient({ provider: deps.cloudAgent.provider.describe().id, use: "model" }));
|
||||
}
|
||||
if (uses.includes("model")) {
|
||||
// Media tools and card actions send prompts, images, video and audio to the owner's servers.
|
||||
const media = await loadMediaSettings(deps);
|
||||
if (media.comfyui) {
|
||||
add(
|
||||
aiRecipient({
|
||||
provider: MEDIA_COMFYUI_PROVIDER_ID,
|
||||
use: "model",
|
||||
baseUrl: media.comfyui.baseUrl,
|
||||
modelId: media.comfyui.imageModel,
|
||||
}),
|
||||
);
|
||||
}
|
||||
if (media.transcription) {
|
||||
add(
|
||||
aiRecipient({
|
||||
provider: MEDIA_TRANSCRIPTION_PROVIDER_ID,
|
||||
use: "model",
|
||||
baseUrl: media.transcription.baseUrl,
|
||||
modelId: media.transcription.model,
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
return {
|
||||
scope: createHash("sha256")
|
||||
.update(JSON.stringify([actor.userId, actor.spaceId]))
|
||||
|
||||
@ -1,5 +1,4 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { rm } from "node:fs/promises";
|
||||
import { ORPCError, onError } from "@orpc/server";
|
||||
import { RPCHandler } from "@orpc/server/fetch";
|
||||
import type {
|
||||
@ -15,6 +14,7 @@ import type {
|
||||
ComposioProvider,
|
||||
ConnectorRegistry,
|
||||
DestinationEmulator,
|
||||
MediaOperationRegistration,
|
||||
RemoteConnectorDependencies,
|
||||
} from "@rakazo/adapters";
|
||||
import {
|
||||
@ -25,6 +25,8 @@ import {
|
||||
createCloudAgentConnection,
|
||||
createConnectorStack,
|
||||
createJobReconciler,
|
||||
createMediaOperations,
|
||||
createMediaRuntime,
|
||||
createMessagingContextLoader,
|
||||
createMessagingTeamChatSender,
|
||||
createProfileConsolidationScheduler,
|
||||
@ -35,18 +37,20 @@ import {
|
||||
destroyBot,
|
||||
EmailEmulator,
|
||||
EncryptedSecretStore,
|
||||
ExpoPushProvider,
|
||||
ExpoPushSender,
|
||||
GraphileJobPublisher,
|
||||
GraphileJobWorkerHost,
|
||||
InMemoryJobQueue,
|
||||
InMemoryRealtimeFanout,
|
||||
InstalledConnectorProvider,
|
||||
IntegrationProviderSettings,
|
||||
importLegacyPushTokens,
|
||||
isComposioEnabled,
|
||||
isMessagingSurfaceEnabled,
|
||||
isPipedreamEnabled,
|
||||
LocalAgentHomeStore,
|
||||
LocalArtifactStore,
|
||||
loadMediaSettings,
|
||||
McpConnector,
|
||||
McpOAuthBroker,
|
||||
messagingPlatformsFromEnv,
|
||||
@ -54,9 +58,9 @@ import {
|
||||
PiOAuthLogins,
|
||||
PipedreamConnector,
|
||||
PostgresRealtimeFanout,
|
||||
PushNotificationProvider,
|
||||
pipedreamConfigFromEnv,
|
||||
piSessionsRoot,
|
||||
pushTokenPath,
|
||||
reconcileCloudAgents,
|
||||
reconcileComputerUpdates,
|
||||
reconcileDelegatedTasks,
|
||||
@ -65,6 +69,9 @@ import {
|
||||
SmtpEmailProvider,
|
||||
SpaceMemoryProviderResolver,
|
||||
toTeamChatInbound,
|
||||
VapidKeys,
|
||||
vapidEnvFromProcess,
|
||||
WebPushSender,
|
||||
} from "@rakazo/adapters";
|
||||
import { blockedAuthPaths, createAuth } from "@rakazo/auth";
|
||||
import { signupPolicyFromEnv } from "@rakazo/core";
|
||||
@ -89,6 +96,7 @@ import { requestLogging } from "@rakazo/logging/hono";
|
||||
import { MarkdownMemoryStore } from "@rakazo/memory";
|
||||
import { Hono } from "hono";
|
||||
import { cors } from "hono/cors";
|
||||
import { mountArtifactHttpRoutes } from "./artifact-http.js";
|
||||
import type { AppEnv } from "./env.js";
|
||||
import { loadEnv } from "./env.js";
|
||||
import { mountLocalSettings } from "./local-settings.js";
|
||||
@ -109,8 +117,8 @@ import {
|
||||
settleWithTimeout,
|
||||
TEAM_CHAT_STARTUP_SHUTDOWN_MS,
|
||||
} from "./team-chat-startup.js";
|
||||
import { mountTradingBotStatus } from "./trading-bot-proxy.js";
|
||||
import { mountVoiceHttpRoutes } from "./voice.js";
|
||||
import { isLoopbackHost, isPrivateOrLocalHost, requestWebOrigin } from "./web-origin.js";
|
||||
import { mountWebhookHttpRoutes } from "./webhook.js";
|
||||
|
||||
export interface AppHandles {
|
||||
@ -139,6 +147,8 @@ export async function createApp(
|
||||
email?: TransactionalEmailProvider;
|
||||
remoteConnectors?: RemoteConnectorDependencies;
|
||||
logger?: Logger;
|
||||
/** Test seam: media operations instead of the configured ones. */
|
||||
mediaOperations?: MediaOperationRegistration[];
|
||||
} = {},
|
||||
): Promise<AppHandles> {
|
||||
const {
|
||||
@ -151,6 +161,7 @@ export async function createApp(
|
||||
email: emailOverride,
|
||||
remoteConnectors,
|
||||
logger: loggerOverride,
|
||||
mediaOperations,
|
||||
...envOverrides
|
||||
} = overrides;
|
||||
const env = { ...loadEnv(process.env), ...envOverrides };
|
||||
@ -309,7 +320,14 @@ export async function createApp(
|
||||
: new PiAgentRuntime({
|
||||
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, {
|
||||
secret: env.authSecret,
|
||||
baseURL: env.authUrl,
|
||||
@ -365,7 +383,6 @@ export async function createApp(
|
||||
),
|
||||
);
|
||||
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.
|
||||
@ -375,6 +392,15 @@ export async function createApp(
|
||||
CLOUD_AGENT_SPACE_ID: env.cloudAgentSpaceId,
|
||||
});
|
||||
const shutdown = new AbortController();
|
||||
const media = createMediaRuntime({
|
||||
prisma,
|
||||
events,
|
||||
jobs,
|
||||
artifacts,
|
||||
registrations:
|
||||
mediaOperations ??
|
||||
createMediaOperations({ loadSettings: () => loadMediaSettings({ prisma, secrets }) }),
|
||||
});
|
||||
const executor = createRunExecutor({
|
||||
prisma,
|
||||
runtime,
|
||||
@ -411,6 +437,7 @@ export async function createApp(
|
||||
messaging: messaging ? createMessagingContextLoader(prisma) : undefined,
|
||||
web: createWebProvider(),
|
||||
cloudAgent,
|
||||
media,
|
||||
shutdownSignal: shutdown.signal,
|
||||
});
|
||||
|
||||
@ -428,6 +455,7 @@ export async function createApp(
|
||||
deploymentModelKey: env.deploymentModelKey,
|
||||
messaging,
|
||||
cloudAgent,
|
||||
media,
|
||||
});
|
||||
if (inMemoryJobs) {
|
||||
await inMemoryJobs.start(jobHandlers);
|
||||
@ -446,12 +474,14 @@ export async function createApp(
|
||||
reconcileComputerUpdates: () => reconcileComputerUpdates({ prisma, jobs }),
|
||||
reconcileProfileConsolidation: createProfileConsolidationScheduler({ prisma, jobs }),
|
||||
reconcileDelegatedTasks: () => reconcileDelegatedTasks({ prisma, jobs, events }),
|
||||
reconcileMediaJobs: () => media.reconcile(),
|
||||
})
|
||||
: undefined;
|
||||
reconciler?.start();
|
||||
|
||||
const router = createRouter({
|
||||
cloudAgent,
|
||||
media,
|
||||
prisma,
|
||||
events,
|
||||
auth,
|
||||
@ -463,6 +493,7 @@ export async function createApp(
|
||||
secrets,
|
||||
oauthLogins,
|
||||
integrationSettings,
|
||||
vapid,
|
||||
mcpOAuth,
|
||||
composio: stack.composio,
|
||||
connectors: stack.connector,
|
||||
@ -513,7 +544,7 @@ export async function createApp(
|
||||
await next();
|
||||
c.res.headers.set(
|
||||
"Content-Security-Policy",
|
||||
"default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; font-src 'self'; connect-src 'self' ws:; frame-ancestors 'none'; base-uri 'self'; form-action 'self'",
|
||||
"default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; media-src 'self' blob:; font-src 'self'; connect-src 'self' ws:; frame-ancestors 'none'; base-uri 'self'; form-action 'self'",
|
||||
);
|
||||
c.res.headers.set("Strict-Transport-Security", "max-age=31536000; includeSubDomains");
|
||||
c.res.headers.set("X-Frame-Options", "DENY");
|
||||
@ -530,13 +561,6 @@ export async function createApp(
|
||||
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") {
|
||||
app.get(
|
||||
"/api/dev/emails",
|
||||
@ -567,7 +591,12 @@ export async function createApp(
|
||||
}
|
||||
const { matched, response } = await rpc.handle(c.req.raw, {
|
||||
prefix: "/rpc",
|
||||
context: { actor, signal: c.req.raw.signal },
|
||||
context: {
|
||||
actor,
|
||||
signal: c.req.raw.signal,
|
||||
userAgent: c.req.header("user-agent"),
|
||||
webOrigin: requestWebOrigin(c.req.header("origin"), env.webOrigin),
|
||||
},
|
||||
});
|
||||
if (matched) return c.newResponse(response.body, response);
|
||||
await next();
|
||||
@ -583,6 +612,13 @@ export async function createApp(
|
||||
if (actor) enrichLogContext({ "user.id": actor.userId, "space.id": actor.spaceId });
|
||||
return actor;
|
||||
});
|
||||
mountArtifactHttpRoutes(app, {
|
||||
prisma,
|
||||
artifacts,
|
||||
sessionUserId: async (request) =>
|
||||
(await auth.api.getSession({ headers: sessionHeaders(request) }))?.user?.id ?? null,
|
||||
trustedOrigin: (origin) => isTrustedOrigin(origin, env),
|
||||
});
|
||||
mountWebhookHttpRoutes(app, { prisma, secrets, events, jobs });
|
||||
// Shared with stop so a shutdown during retry delays does not restart polling.
|
||||
let messagingStopped = false;
|
||||
@ -977,20 +1013,6 @@ function isTrustedOrigin(origin: string, env: AppEnv) {
|
||||
}
|
||||
}
|
||||
|
||||
function isPrivateOrLocalHost(host: string): boolean {
|
||||
if (isLoopbackHost(host)) return true;
|
||||
if (host.endsWith(".local")) return true;
|
||||
if (/^192\.168\.\d{1,3}\.\d{1,3}$/.test(host)) return true;
|
||||
if (/^10\.\d{1,3}\.\d{1,3}\.\d{1,3}$/.test(host)) return true;
|
||||
if (/^172\.(1[6-9]|2\d|3[01])\.\d{1,3}\.\d{1,3}$/.test(host)) return true;
|
||||
if (/^100\.(6[4-9]|[7-9]\d|1[01]\d|12[0-7])\.\d{1,3}\.\d{1,3}$/.test(host)) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
function isLoopbackHost(host: string): boolean {
|
||||
return host === "localhost" || host === "127.0.0.1" || host === "::1" || host === "[::1]";
|
||||
}
|
||||
|
||||
function sessionHeaders(request: Request) {
|
||||
const headers = new Headers(request.headers);
|
||||
const authz = headers.get("authorization");
|
||||
|
||||
275
apps/api/src/artifact-http.test.ts
Normal file
275
apps/api/src/artifact-http.test.ts
Normal file
@ -0,0 +1,275 @@
|
||||
import { mkdtemp, readdir, rm } from "node:fs/promises";
|
||||
import { createServer } from "node:http";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import type { ArtifactStore } from "@rakazo/adapter-kit";
|
||||
import { LocalArtifactStore } from "@rakazo/adapters";
|
||||
import type { PrismaClient } from "@rakazo/db";
|
||||
import { Hono } from "hono";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
API_SERVER_OPTIONS,
|
||||
contentDisposition,
|
||||
mountArtifactHttpRoutes,
|
||||
parseByteRange,
|
||||
stallGuardedChunks,
|
||||
UploadStalledError,
|
||||
} from "./artifact-http.js";
|
||||
|
||||
describe("parseByteRange", () => {
|
||||
it("ignores an absent header or another unit", () => {
|
||||
expect(parseByteRange(undefined, 100)).toBeNull();
|
||||
expect(parseByteRange("", 100)).toBeNull();
|
||||
expect(parseByteRange("items=0-1", 100)).toBeNull();
|
||||
});
|
||||
|
||||
it("parses closed, open and suffix ranges and clamps the end to the size", () => {
|
||||
expect(parseByteRange("bytes=0-9", 100)).toEqual({ start: 0, end: 9 });
|
||||
expect(parseByteRange("bytes=90-", 100)).toEqual({ start: 90, end: 99 });
|
||||
expect(parseByteRange("bytes=-10", 100)).toEqual({ start: 90, end: 99 });
|
||||
expect(parseByteRange("bytes=-500", 100)).toEqual({ start: 0, end: 99 });
|
||||
expect(parseByteRange("bytes=50-1000", 100)).toEqual({ start: 50, end: 99 });
|
||||
expect(parseByteRange("Bytes= 0-0 ", 100)).toEqual({ start: 0, end: 0 });
|
||||
});
|
||||
|
||||
it("rejects multiple, inverted, out-of-file and malformed ranges", () => {
|
||||
for (const header of [
|
||||
"bytes=0-1,5-6",
|
||||
"bytes=5-4",
|
||||
"bytes=100-",
|
||||
"bytes=100-200",
|
||||
"bytes=-0",
|
||||
"bytes=-",
|
||||
"bytes=a-b",
|
||||
"bytes=1.5-2",
|
||||
"bytes= -1-2",
|
||||
"bytes=0x10-20",
|
||||
`bytes=${"9".repeat(16)}-`,
|
||||
]) {
|
||||
expect(parseByteRange(header, 100), header).toBe("unsatisfiable");
|
||||
}
|
||||
expect(parseByteRange("bytes=0-0", 0)).toBe("unsatisfiable");
|
||||
});
|
||||
});
|
||||
|
||||
describe("contentDisposition", () => {
|
||||
it("keeps plain names and marks media inline", () => {
|
||||
expect(contentDisposition("clip.mp4", true)).toBe(
|
||||
`inline; filename="clip.mp4"; filename*=UTF-8''clip.mp4`,
|
||||
);
|
||||
});
|
||||
|
||||
it("neutralizes quotes, separators, control characters and header injection", () => {
|
||||
const header = contentDisposition('../evil"\r\nSet-Cookie: x=1;.txt', false);
|
||||
expect(header.startsWith("attachment; ")).toBe(true);
|
||||
expect(header).not.toMatch(/[\r\n]/);
|
||||
expect(header).toContain(`filename=".._evil___Set-Cookie: x=1_.txt"`);
|
||||
expect(header).not.toContain("/");
|
||||
});
|
||||
|
||||
it("encodes non-ASCII names with an ASCII fallback", () => {
|
||||
expect(contentDisposition("Grüße (1).png", true)).toBe(
|
||||
`inline; filename="Gr__e (1).png"; filename*=UTF-8''Gr%C3%BC%C3%9Fe%20%281%29.png`,
|
||||
);
|
||||
expect(contentDisposition(" ", false)).toBe(
|
||||
`attachment; filename="download"; filename*=UTF-8''download`,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("artifact HTTP routes", () => {
|
||||
function app(sessionUserId: () => Promise<string | null>) {
|
||||
const prisma = {
|
||||
artifact: { findUnique: vi.fn(), create: vi.fn() },
|
||||
spaceMember: { findFirst: vi.fn() },
|
||||
};
|
||||
const artifacts = { putStream: vi.fn(), stat: vi.fn(), createReadStream: vi.fn() };
|
||||
const hono = new Hono();
|
||||
mountArtifactHttpRoutes(hono, {
|
||||
prisma: prisma as unknown as PrismaClient,
|
||||
artifacts: artifacts as unknown as ArtifactStore,
|
||||
sessionUserId,
|
||||
trustedOrigin: (origin) => origin === "http://127.0.0.1:5173",
|
||||
});
|
||||
return { hono, prisma, artifacts };
|
||||
}
|
||||
|
||||
it("rejects signed-out reads and uploads before touching storage", async () => {
|
||||
const { hono, prisma, artifacts } = app(async () => null);
|
||||
const read = await hono.request("/api/artifacts/art-1");
|
||||
const upload = await hono.request("/api/artifacts?botId=bot-1&name=a.mp4", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "video/mp4" },
|
||||
body: "data",
|
||||
});
|
||||
expect(read.status).toBe(401);
|
||||
expect(upload.status).toBe(401);
|
||||
expect(prisma.artifact.findUnique).not.toHaveBeenCalled();
|
||||
expect(artifacts.putStream).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("refuses uploads from untrusted origins before reading the session", async () => {
|
||||
const sessionUserId = vi.fn(async () => "user-1");
|
||||
const { hono, artifacts } = app(sessionUserId);
|
||||
const response = await hono.request("/api/artifacts?botId=bot-1&name=a.txt", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "text/plain", origin: "https://evil.example" },
|
||||
body: "data",
|
||||
});
|
||||
expect(response.status).toBe(403);
|
||||
expect(sessionUserId).not.toHaveBeenCalled();
|
||||
expect(artifacts.putStream).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("answers ids that are not artifact ids with 404 without a lookup", async () => {
|
||||
const { hono, prisma } = app(async () => "user-1");
|
||||
const response = await hono.request(`/api/artifacts/${encodeURIComponent("../etc/passwd")}`);
|
||||
expect(response.status).toBe(404);
|
||||
expect(prisma.artifact.findUnique).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects declared oversized and disallowed uploads without reading the body", async () => {
|
||||
const { hono, prisma, artifacts } = app(async () => "user-1");
|
||||
prisma.spaceMember.findFirst.mockResolvedValue({
|
||||
userId: "user-1",
|
||||
spaceId: "space-1",
|
||||
member: { user: { email: "user@example.test" } },
|
||||
});
|
||||
(prisma as unknown as { deploymentSettings: unknown }).deploymentSettings = {
|
||||
findUnique: vi.fn(async () => null),
|
||||
};
|
||||
const cancel = vi.fn(() => new Promise<void>(() => undefined));
|
||||
const oversized = await hono.request(
|
||||
new Request("http://localhost/api/artifacts?botId=bot-1&name=a.mp4", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "video/mp4", "content-length": String(501 * 1024 * 1024) },
|
||||
body: new ReadableStream({ cancel }),
|
||||
duplex: "half",
|
||||
} as RequestInit & { duplex: "half" }),
|
||||
);
|
||||
expect(oversized.status).toBe(413);
|
||||
expect(cancel).toHaveBeenCalled();
|
||||
|
||||
const html = await hono.request("/api/artifacts?botId=bot-1&name=a.html", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "text/html" },
|
||||
body: "<script></script>",
|
||||
});
|
||||
expect(html.status).toBe(415);
|
||||
|
||||
const noTarget = await hono.request("/api/artifacts?name=a.mp4", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "video/mp4" },
|
||||
body: "data",
|
||||
});
|
||||
expect(noTarget.status).toBe(400);
|
||||
expect(artifacts.putStream).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("upload timeouts", () => {
|
||||
const dirs: string[] = [];
|
||||
afterEach(async () => {
|
||||
await Promise.all(dirs.splice(0).map((dir) => rm(dir, { recursive: true, force: true })));
|
||||
});
|
||||
|
||||
it("gives the server time for large uploads but keeps the header timeout", () => {
|
||||
const server = createServer(API_SERVER_OPTIONS);
|
||||
expect(server.requestTimeout).toBe(30 * 60 * 1000);
|
||||
expect(server.headersTimeout).toBe(createServer().headersTimeout);
|
||||
});
|
||||
|
||||
/** A body that sends `chunks` at `gapMs` intervals, then stays open forever if `hang`. */
|
||||
function drip(chunks: string[], gapMs: number, hang: boolean) {
|
||||
const cancel = vi.fn(() => new Promise<void>(() => undefined));
|
||||
let sent = 0;
|
||||
const body = new ReadableStream<Uint8Array>({
|
||||
async pull(controller) {
|
||||
if (sent === chunks.length) {
|
||||
if (!hang) controller.close();
|
||||
else await new Promise(() => undefined);
|
||||
return;
|
||||
}
|
||||
if (sent > 0) await new Promise((resolve) => setTimeout(resolve, gapMs));
|
||||
controller.enqueue(new TextEncoder().encode(chunks[sent++]));
|
||||
},
|
||||
cancel,
|
||||
});
|
||||
return { body, cancel };
|
||||
}
|
||||
|
||||
it("throws once the body stops sending and cancels it without waiting", async () => {
|
||||
const { body, cancel } = drip(["ab", "cd"], 5, true);
|
||||
const seen: string[] = [];
|
||||
await expect(
|
||||
(async () => {
|
||||
for await (const chunk of stallGuardedChunks(body, 50)) {
|
||||
seen.push(new TextDecoder().decode(chunk));
|
||||
}
|
||||
})(),
|
||||
).rejects.toBeInstanceOf(UploadStalledError);
|
||||
expect(seen).toEqual(["ab", "cd"]);
|
||||
expect(cancel).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
async function postDrip(stallMs: number, body: ReadableStream<Uint8Array>) {
|
||||
const root = await mkdtemp(path.join(tmpdir(), "rakazo-artifact-upload-"));
|
||||
dirs.push(root);
|
||||
const create = vi.fn(async ({ data }: { data: Record<string, unknown> }) => ({
|
||||
id: "art-1",
|
||||
createdAt: new Date(),
|
||||
...data,
|
||||
}));
|
||||
const prisma = {
|
||||
artifact: { create },
|
||||
spaceMember: {
|
||||
findFirst: vi.fn(async () => ({
|
||||
userId: "user-1",
|
||||
spaceId: "space-1",
|
||||
member: { user: { email: "user@example.test" } },
|
||||
})),
|
||||
},
|
||||
deploymentSettings: { findUnique: vi.fn(async () => null) },
|
||||
bot: { findFirst: vi.fn(async () => ({ id: "bot-1" })) },
|
||||
};
|
||||
const hono = new Hono();
|
||||
mountArtifactHttpRoutes(hono, {
|
||||
prisma: prisma as unknown as PrismaClient,
|
||||
artifacts: new LocalArtifactStore(root),
|
||||
sessionUserId: async () => "user-1",
|
||||
trustedOrigin: () => true,
|
||||
uploadStallTimeoutMs: stallMs,
|
||||
});
|
||||
const response = await hono.request(
|
||||
new Request("http://localhost/api/artifacts?botId=bot-1&name=clip.mp4", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "video/mp4" },
|
||||
body,
|
||||
duplex: "half",
|
||||
} as RequestInit & { duplex: "half" }),
|
||||
);
|
||||
// The store creates the space directory before it reads the body, so it must exist here.
|
||||
const stored = await readdir(path.join(root, "artifacts", "space-1"));
|
||||
return { response, create, stored };
|
||||
}
|
||||
|
||||
it("aborts a stalled upload with 408 and deletes the partial file", async () => {
|
||||
const { body, cancel } = drip(["partial video bytes"], 0, true);
|
||||
const { response, create, stored } = await postDrip(50, body);
|
||||
expect(response.status).toBe(408);
|
||||
expect(await response.json()).toEqual({ error: "Upload stalled." });
|
||||
expect(cancel).toHaveBeenCalled();
|
||||
expect(create).not.toHaveBeenCalled();
|
||||
expect(stored).toEqual([]);
|
||||
});
|
||||
|
||||
it("keeps a slow but steady upload going past the stall timeout", async () => {
|
||||
const { body } = drip(["a", "b", "c", "d", "e", "f"], 30, false);
|
||||
const { response, create, stored } = await postDrip(100, body);
|
||||
expect(response.status).toBe(201);
|
||||
expect(create).toHaveBeenCalledWith({
|
||||
data: expect.objectContaining({ size: 6, mimeType: "video/mp4", botId: "bot-1" }),
|
||||
});
|
||||
expect(stored).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
336
apps/api/src/artifact-http.ts
Normal file
336
apps/api/src/artifact-http.ts
Normal file
@ -0,0 +1,336 @@
|
||||
import type { AdapterContext, ArtifactByteRange, ArtifactStore } from "@rakazo/adapter-kit";
|
||||
import { ArtifactTooLargeError } from "@rakazo/adapter-kit";
|
||||
import { artifactMaxBytes, storeArtifactStream } from "@rakazo/adapters";
|
||||
import type { Actor } from "@rakazo/contracts";
|
||||
import { attachmentKindOf, isAllowedAttachmentMimeType } from "@rakazo/contracts";
|
||||
import type { PrismaClient } from "@rakazo/db";
|
||||
import { createGroupRepos, createRepos, IsolationError, requireMembership } from "@rakazo/db";
|
||||
import type { Context, Hono } from "hono";
|
||||
import { cancelBody } from "./http-body.js";
|
||||
|
||||
export const ARTIFACT_HTTP_PATH = "/api/artifacts";
|
||||
|
||||
/**
|
||||
* Node's default 5-minute requestTimeout cuts off a 500 MiB video upload on any uplink slower than
|
||||
* about 14 Mbit/s; 30 minutes fits it at about 2.5 Mbit/s. headersTimeout keeps its default, and
|
||||
* the per-upload stall timeout stops a slow-drip client from holding a connection that long.
|
||||
*/
|
||||
export const API_SERVER_OPTIONS = { requestTimeout: 30 * 60 * 1000 };
|
||||
/** An upload that sends no bytes for this long is aborted and its partial file deleted. */
|
||||
export const ARTIFACT_UPLOAD_STALL_TIMEOUT_MS = 60_000;
|
||||
|
||||
export class UploadStalledError extends Error {
|
||||
constructor() {
|
||||
super("Upload stalled");
|
||||
this.name = "UploadStalledError";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Yields the body's chunks and throws UploadStalledError when the next chunk takes longer than
|
||||
* stallMs. Only time spent waiting for the client counts, not time spent writing to disk.
|
||||
*/
|
||||
export async function* stallGuardedChunks(
|
||||
body: ReadableStream<Uint8Array>,
|
||||
stallMs: number,
|
||||
): AsyncGenerator<Uint8Array> {
|
||||
const reader = body.getReader();
|
||||
try {
|
||||
for (;;) {
|
||||
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||
const stalled = new Promise<never>((_, reject) => {
|
||||
timer = setTimeout(() => reject(new UploadStalledError()), stallMs);
|
||||
});
|
||||
try {
|
||||
const { done, value } = await Promise.race([reader.read(), stalled]);
|
||||
if (done) return;
|
||||
yield value;
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
// Never wait on the client here: a stalled or hostile body may never confirm the cancel.
|
||||
void reader.cancel().catch(() => undefined);
|
||||
reader.releaseLock();
|
||||
}
|
||||
}
|
||||
|
||||
export type ArtifactHttpDeps = {
|
||||
prisma: PrismaClient;
|
||||
artifacts: ArtifactStore;
|
||||
/** The signed-in user (cookie or bearer session), or null. */
|
||||
sessionUserId: (request: Request) => Promise<string | null>;
|
||||
/** Same rule as CORS. Raw uploads can be CORS "simple" requests, so the origin is checked. */
|
||||
trustedOrigin: (origin: string) => boolean;
|
||||
/** Overrides ARTIFACT_UPLOAD_STALL_TIMEOUT_MS (tests). */
|
||||
uploadStallTimeoutMs?: number;
|
||||
};
|
||||
|
||||
/** Generated ids only; anything else is not an artifact. */
|
||||
const ARTIFACT_ID = /^[A-Za-z0-9_-]{1,64}$/;
|
||||
const MAX_RANGE_DIGITS = 15;
|
||||
|
||||
/**
|
||||
* Parses a single `bytes=` range against the stored size. Returns null when the header should
|
||||
* be ignored (absent or another unit) and "unsatisfiable" for multiple ranges or offsets
|
||||
* outside the file, which the caller answers with 416.
|
||||
*/
|
||||
export function parseByteRange(
|
||||
header: string | undefined,
|
||||
size: number,
|
||||
): ArtifactByteRange | "unsatisfiable" | null {
|
||||
if (!header) return null;
|
||||
const value = header.trim();
|
||||
if (!value.toLowerCase().startsWith("bytes=")) return null;
|
||||
const spec = value.slice("bytes=".length).trim();
|
||||
const match = new RegExp(`^(\\d{0,${MAX_RANGE_DIGITS}})-(\\d{0,${MAX_RANGE_DIGITS}})$`).exec(
|
||||
spec,
|
||||
);
|
||||
if (!match || size <= 0) return "unsatisfiable";
|
||||
const [, first = "", last = ""] = match;
|
||||
if (first === "" && last === "") return "unsatisfiable";
|
||||
if (first === "") {
|
||||
const suffix = Number(last);
|
||||
if (suffix <= 0) return "unsatisfiable";
|
||||
return { start: Math.max(0, size - suffix), end: size - 1 };
|
||||
}
|
||||
const start = Number(first);
|
||||
const end = last === "" ? size - 1 : Number(last);
|
||||
if (start >= size || end < start) return "unsatisfiable";
|
||||
return { start, end: Math.min(end, size - 1) };
|
||||
}
|
||||
|
||||
/** `inline` or `attachment` with an ASCII fallback and the exact UTF-8 name (RFC 6266). */
|
||||
export function contentDisposition(name: string, inline: boolean): string {
|
||||
const cleaned =
|
||||
Array.from(name, (char) => {
|
||||
const code = char.codePointAt(0) ?? 0;
|
||||
return code < 0x20 || code === 0x7f || char === "/" || char === "\\" ? "_" : char;
|
||||
})
|
||||
.join("")
|
||||
.trim() || "download";
|
||||
const ascii = cleaned.replace(/[^ -~]/g, "_").replace(/["%;]/g, "_");
|
||||
const encoded = encodeURIComponent(cleaned).replace(
|
||||
/['()*]/g,
|
||||
(char) => `%${char.charCodeAt(0).toString(16).toUpperCase()}`,
|
||||
);
|
||||
return `${inline ? "inline" : "attachment"}; filename="${ascii}"; filename*=UTF-8''${encoded}`;
|
||||
}
|
||||
|
||||
function adapterContext(actor: Actor, operationId: string, signal: AbortSignal): AdapterContext {
|
||||
return {
|
||||
operationId,
|
||||
traceId: operationId,
|
||||
spaceId: actor.spaceId,
|
||||
userId: actor.userId,
|
||||
signal,
|
||||
};
|
||||
}
|
||||
|
||||
function notFound(c: Context) {
|
||||
return c.json({ error: "Not found" }, 404);
|
||||
}
|
||||
|
||||
function artifactDto(row: {
|
||||
id: string;
|
||||
botId: string | null;
|
||||
groupId: string | null;
|
||||
runId: string | null;
|
||||
name: string;
|
||||
mimeType: string;
|
||||
size: number;
|
||||
createdAt: Date;
|
||||
}) {
|
||||
return {
|
||||
id: row.id,
|
||||
botId: row.botId,
|
||||
groupId: row.groupId,
|
||||
runId: row.runId,
|
||||
name: row.name,
|
||||
mimeType: row.mimeType,
|
||||
size: row.size,
|
||||
createdAt: row.createdAt.toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Streams artifacts over HTTP so media can play with seeking and large files never travel as
|
||||
* base64 RPC payloads. Authorization matches `artifacts.get` and `artifacts.create`.
|
||||
*/
|
||||
export function mountArtifactHttpRoutes(app: Hono, deps: ArtifactHttpDeps) {
|
||||
const repos = createRepos(deps.prisma);
|
||||
const groupRepos = createGroupRepos(deps.prisma);
|
||||
|
||||
/** Same rule as artifacts.get: the owner's artifact of an active bot thread or group. */
|
||||
async function readableArtifact(
|
||||
userId: string,
|
||||
requestedSpaceId: string | undefined,
|
||||
id: string,
|
||||
) {
|
||||
if (!ARTIFACT_ID.test(id)) return null;
|
||||
const row = await deps.prisma.artifact.findUnique({ where: { id } });
|
||||
if (!row) return null;
|
||||
// <video> and <img> cannot send the space header, so the artifact's space is the default;
|
||||
// membership is still required and an explicit header must name the same space.
|
||||
const actor = await requireMembership(
|
||||
deps.prisma,
|
||||
userId,
|
||||
requestedSpaceId ?? row.spaceId,
|
||||
).catch(() => null);
|
||||
if (!actor || actor.spaceId !== row.spaceId || actor.userId !== row.userId) return null;
|
||||
try {
|
||||
if (row.groupId) await groupRepos.getGroupTarget(actor, row.groupId);
|
||||
else if (row.botId) await repos.getBot(actor, row.botId);
|
||||
else return null;
|
||||
} catch (error) {
|
||||
if (error instanceof IsolationError) return null;
|
||||
throw error;
|
||||
}
|
||||
return { row, actor };
|
||||
}
|
||||
|
||||
app.get(`${ARTIFACT_HTTP_PATH}/:id`, async (c) => {
|
||||
const userId = await deps.sessionUserId(c.req.raw);
|
||||
if (!userId) return c.json({ error: "Unauthorized" }, 401);
|
||||
const found = await readableArtifact(
|
||||
userId,
|
||||
c.req.header("x-rakazo-space-id"),
|
||||
c.req.param("id"),
|
||||
);
|
||||
if (!found) return notFound(c);
|
||||
const { row, actor } = found;
|
||||
const context = adapterContext(actor, `artifact-http-get:${row.id}`, c.req.raw.signal);
|
||||
let size: number;
|
||||
try {
|
||||
({ size } = await deps.artifacts.stat(row.storageKey, context));
|
||||
} catch {
|
||||
return notFound(c);
|
||||
}
|
||||
|
||||
const mimeType = isAllowedAttachmentMimeType(row.mimeType)
|
||||
? row.mimeType
|
||||
: "application/octet-stream";
|
||||
const inline = attachmentKindOf(mimeType) !== "file" && c.req.query("download") === undefined;
|
||||
const etag = /^[A-Za-z0-9]{16,128}$/.test(row.hash) ? `"${row.hash}"` : undefined;
|
||||
const headers = new Headers({
|
||||
"accept-ranges": "bytes",
|
||||
// Revalidate every time so a revoked thread stops serving from the browser cache.
|
||||
"cache-control": "private, no-cache",
|
||||
"content-disposition": contentDisposition(row.name, inline),
|
||||
"content-type": mimeType,
|
||||
"cross-origin-resource-policy": "same-origin",
|
||||
"x-content-type-options": "nosniff",
|
||||
});
|
||||
if (etag) headers.set("etag", etag);
|
||||
if (etag && c.req.header("if-none-match") === etag) {
|
||||
return new Response(null, { status: 304, headers });
|
||||
}
|
||||
|
||||
const ifRange = c.req.header("if-range");
|
||||
const range =
|
||||
ifRange === undefined || ifRange === etag
|
||||
? parseByteRange(c.req.header("range"), size)
|
||||
: null;
|
||||
if (range === "unsatisfiable") {
|
||||
headers.set("content-range", `bytes */${size}`);
|
||||
return new Response(null, { status: 416, headers });
|
||||
}
|
||||
const status = range ? 206 : 200;
|
||||
headers.set("content-length", String(range ? range.end - range.start + 1 : size));
|
||||
if (range) headers.set("content-range", `bytes ${range.start}-${range.end}/${size}`);
|
||||
// Hono answers HEAD from this handler and drops the body; never open a stream for it.
|
||||
if (c.req.method === "HEAD") return new Response(null, { status, headers });
|
||||
|
||||
let body: ReadableStream<Uint8Array>;
|
||||
try {
|
||||
body = await deps.artifacts.createReadStream(row.storageKey, context, range ?? undefined);
|
||||
} catch {
|
||||
return notFound(c);
|
||||
}
|
||||
return new Response(body, { status, headers });
|
||||
});
|
||||
|
||||
app.post(ARTIFACT_HTTP_PATH, async (c) => {
|
||||
const request = c.req.raw;
|
||||
const reject = (status: 400 | 401 | 403 | 404 | 408 | 413 | 415, error: string) => {
|
||||
cancelBody(request.body);
|
||||
return c.json({ error }, status);
|
||||
};
|
||||
const origin = c.req.header("origin");
|
||||
if (origin && !deps.trustedOrigin(origin)) return reject(403, "Forbidden");
|
||||
const userId = await deps.sessionUserId(request);
|
||||
if (!userId) return reject(401, "Unauthorized");
|
||||
const actor = await requireMembership(
|
||||
deps.prisma,
|
||||
userId,
|
||||
c.req.header("x-rakazo-space-id"),
|
||||
).catch(() => null);
|
||||
if (!actor) return reject(401, "Unauthorized");
|
||||
|
||||
const botId = c.req.query("botId") || undefined;
|
||||
const groupId = c.req.query("groupId") || undefined;
|
||||
const name = c.req.query("name") ?? "";
|
||||
if (Boolean(botId) === Boolean(groupId)) {
|
||||
return reject(400, "Provide exactly one of botId or groupId");
|
||||
}
|
||||
if (name.length < 1 || name.length > 255) return reject(400, "Invalid file name");
|
||||
const mimeType = (c.req.header("content-type") ?? "").split(";", 1)[0]!.trim().toLowerCase();
|
||||
if (!isAllowedAttachmentMimeType(mimeType)) {
|
||||
return reject(415, "Unsupported attachment type");
|
||||
}
|
||||
const maxBytes = artifactMaxBytes(mimeType);
|
||||
const declared = c.req.header("content-length");
|
||||
if (declared !== undefined) {
|
||||
if (!/^\d{1,16}$/.test(declared)) return reject(400, "Invalid content length");
|
||||
if (Number(declared) > maxBytes) return reject(413, "File is too large.");
|
||||
if (Number(declared) === 0) return reject(400, "File is empty.");
|
||||
}
|
||||
if (!request.body) return reject(400, "File is empty.");
|
||||
|
||||
// Same targets as artifacts.create: an own active bot, or an active group of own bots.
|
||||
let ownerBotId: string | undefined;
|
||||
try {
|
||||
ownerBotId = botId
|
||||
? (await repos.getBot(actor, botId)).id
|
||||
: (await groupRepos.getGroupTarget(actor, groupId!)).members[0]?.bot.id;
|
||||
} catch (error) {
|
||||
if (!(error instanceof IsolationError)) throw error;
|
||||
}
|
||||
if (!ownerBotId) return reject(404, "Not found");
|
||||
|
||||
let row: Awaited<ReturnType<typeof storeArtifactStream>>;
|
||||
try {
|
||||
row = await storeArtifactStream(deps, {
|
||||
spaceId: actor.spaceId,
|
||||
userId: actor.userId,
|
||||
botId: ownerBotId,
|
||||
groupId: groupId ?? null,
|
||||
runId: null,
|
||||
name,
|
||||
mimeType,
|
||||
stream: stallGuardedChunks(
|
||||
request.body,
|
||||
deps.uploadStallTimeoutMs ?? ARTIFACT_UPLOAD_STALL_TIMEOUT_MS,
|
||||
),
|
||||
maxBytes,
|
||||
signal: request.signal,
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof ArtifactTooLargeError) return reject(413, "File is too large.");
|
||||
if (error instanceof UploadStalledError) return reject(408, "Upload stalled.");
|
||||
throw error;
|
||||
}
|
||||
if (row.size === 0) {
|
||||
await deps.prisma.artifact.delete({ where: { id: row.id } });
|
||||
await deps.artifacts
|
||||
.remove(
|
||||
row.storageKey,
|
||||
adapterContext(actor, `artifact-http-post:${row.id}`, request.signal),
|
||||
)
|
||||
.catch(() => undefined);
|
||||
return c.json({ error: "File is empty." }, 400);
|
||||
}
|
||||
return c.json(artifactDto(row), 201);
|
||||
});
|
||||
}
|
||||
@ -11,6 +11,12 @@ import {
|
||||
} from "@rakazo/core";
|
||||
import { IsolationError, type PrismaClient } from "@rakazo/db";
|
||||
|
||||
/**
|
||||
* Base64 reads load the whole file into memory; larger media is served by
|
||||
* GET /api/artifacts/:id instead.
|
||||
*/
|
||||
export const ARTIFACT_INLINE_READ_MAX_BYTES = 25 * 1024 * 1024;
|
||||
|
||||
function adapterContext(actor: Actor, botId: string, operationId: string) {
|
||||
return {
|
||||
operationId,
|
||||
@ -131,6 +137,9 @@ async function readArtifact(
|
||||
},
|
||||
contextBotId: string,
|
||||
) {
|
||||
if (row.size > ARTIFACT_INLINE_READ_MAX_BYTES) {
|
||||
throw new AttachmentValidationError("This file is too large to load inline.");
|
||||
}
|
||||
const bytes = await artifacts.get(
|
||||
row.storageKey,
|
||||
adapterContext(actor, contextBotId, `artifact-get:${row.id}`),
|
||||
|
||||
106
apps/api/src/autopilot.ts
Normal file
106
apps/api/src/autopilot.ts
Normal file
@ -0,0 +1,106 @@
|
||||
import { ORPCError } from "@orpc/server";
|
||||
import type { JobPublisher, SandboxProvider } from "@rakazo/adapter-kit";
|
||||
import {
|
||||
autopilotView,
|
||||
endAutopilot,
|
||||
findOpenAutopilot,
|
||||
kickAutopilot,
|
||||
resumeAutopilot,
|
||||
startAutopilot,
|
||||
} from "@rakazo/adapters";
|
||||
import type { Actor, Autopilot, AutopilotRecent } from "@rakazo/contracts";
|
||||
import { AUTOPILOT_RECENT_MS } from "@rakazo/contracts";
|
||||
import type { PrismaClient, ThreadEvents } from "@rakazo/db";
|
||||
import { IsolationError } from "@rakazo/db";
|
||||
import type { ThreadTarget } from "./thread-target.js";
|
||||
import { groupDefaultBotId, stopThreadRuns } from "./thread-target.js";
|
||||
|
||||
type AutopilotDeps = {
|
||||
prisma: PrismaClient;
|
||||
events: ThreadEvents;
|
||||
jobs: JobPublisher;
|
||||
sandbox: SandboxProvider;
|
||||
};
|
||||
|
||||
function owner(actor: Actor, target: ThreadTarget) {
|
||||
const botId = target.kind === "bot" ? target.botId : groupDefaultBotId(target);
|
||||
if (!botId) throw new IsolationError();
|
||||
return { spaceId: actor.spaceId, userId: actor.userId, botId, threadId: target.threadId };
|
||||
}
|
||||
|
||||
export async function getThreadAutopilot(
|
||||
deps: Pick<AutopilotDeps, "prisma">,
|
||||
actor: Actor,
|
||||
target: ThreadTarget,
|
||||
): Promise<Autopilot | null> {
|
||||
const open = await findOpenAutopilot(deps.prisma, owner(actor, target));
|
||||
return open ? autopilotView(open) : null;
|
||||
}
|
||||
|
||||
/** Start from the composer: the first run begins at once. */
|
||||
export async function startThreadAutopilot(
|
||||
deps: AutopilotDeps,
|
||||
actor: Actor,
|
||||
target: ThreadTarget,
|
||||
input: { goal: string; focus?: string; until?: string },
|
||||
): Promise<Autopilot> {
|
||||
const started = await startAutopilot(deps, owner(actor, target), {
|
||||
goal: input.goal,
|
||||
focus: input.focus,
|
||||
until: input.until ? new Date(input.until) : undefined,
|
||||
});
|
||||
if ("error" in started) throw new ORPCError("CONFLICT", { message: started.error });
|
||||
await kickAutopilot(deps, started.autopilot.id);
|
||||
return autopilotView(started.autopilot);
|
||||
}
|
||||
|
||||
/** Stop at once: end the autopilot first so no run starts another, then stop what runs. */
|
||||
export async function stopThreadAutopilot(
|
||||
deps: AutopilotDeps,
|
||||
actor: Actor,
|
||||
target: ThreadTarget,
|
||||
): Promise<{ ok: true }> {
|
||||
const open = await findOpenAutopilot(deps.prisma, owner(actor, target));
|
||||
if (open && (await endAutopilot(deps, open.id, { status: "stopped" }))) {
|
||||
await stopThreadRuns(deps, actor, target);
|
||||
}
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
export async function resumeThreadAutopilot(
|
||||
deps: AutopilotDeps,
|
||||
actor: Actor,
|
||||
target: ThreadTarget,
|
||||
): Promise<{ ok: true }> {
|
||||
const open = await findOpenAutopilot(deps.prisma, owner(actor, target));
|
||||
if (open?.status !== "paused" || !(await resumeAutopilot(deps, open.id))) {
|
||||
throw new ORPCError("CONFLICT", { message: "This autopilot is not paused." });
|
||||
}
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
/** Today's view: the user's open autopilots in this space and those that ended within a day. */
|
||||
export async function listRecentAutopilots(
|
||||
deps: Pick<AutopilotDeps, "prisma">,
|
||||
actor: Actor,
|
||||
now = new Date(),
|
||||
): Promise<AutopilotRecent[]> {
|
||||
const rows = await deps.prisma.autopilot.findMany({
|
||||
where: {
|
||||
spaceId: actor.spaceId,
|
||||
userId: actor.userId,
|
||||
OR: [
|
||||
{ status: { in: ["active", "paused"] } },
|
||||
{ endedAt: { gte: new Date(now.getTime() - AUTOPILOT_RECENT_MS) } },
|
||||
],
|
||||
},
|
||||
include: { thread: { select: { groupId: true } } },
|
||||
orderBy: { startedAt: "desc" },
|
||||
take: 10,
|
||||
});
|
||||
return rows.map((row) => ({
|
||||
...autopilotView(row),
|
||||
spaceId: row.spaceId,
|
||||
groupId: row.thread.groupId,
|
||||
}));
|
||||
}
|
||||
74
apps/api/src/bot-import.test.ts
Normal file
74
apps/api/src/bot-import.test.ts
Normal file
@ -0,0 +1,74 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { importableRoutines, relativeImportedFiles, safeImportPath } from "./bot-import.js";
|
||||
|
||||
describe("safeImportPath", () => {
|
||||
it("normalizes separators and dot segments", () => {
|
||||
expect(safeImportPath("notes\\./result.txt")).toBe("notes/result.txt");
|
||||
expect(safeImportPath("MEMORY.md")).toBe("MEMORY.md");
|
||||
});
|
||||
|
||||
it("rejects paths that could leave the bot home", () => {
|
||||
expect(safeImportPath("../secrets")).toBeNull();
|
||||
expect(safeImportPath("notes/../../x")).toBeNull();
|
||||
expect(safeImportPath("/etc/passwd")).toBeNull();
|
||||
expect(safeImportPath("C:\\Windows")).toBeNull();
|
||||
expect(safeImportPath("./")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("relativeImportedFiles", () => {
|
||||
it("drops the old Team Computer workspace prefix", () => {
|
||||
expect(
|
||||
relativeImportedFiles([
|
||||
{ path: "bots/old-id/notes/result.txt", content: "a" },
|
||||
{ path: "bots/old-id/README.md", content: "b" },
|
||||
]),
|
||||
).toEqual([
|
||||
{ path: "notes/result.txt", content: "a" },
|
||||
{ path: "README.md", content: "b" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("keeps Private Computer paths as they are", () => {
|
||||
const files = [
|
||||
{ path: "notes/result.txt", content: "a" },
|
||||
{ path: "bots/other/file.txt", content: "b" },
|
||||
];
|
||||
expect(relativeImportedFiles(files)).toEqual(files);
|
||||
});
|
||||
|
||||
it("keeps paths when files span several workspaces", () => {
|
||||
const files = [
|
||||
{ path: "bots/a/x.txt", content: "a" },
|
||||
{ path: "bots/b/y.txt", content: "b" },
|
||||
];
|
||||
expect(relativeImportedFiles(files)).toEqual(files);
|
||||
});
|
||||
|
||||
it("refuses the whole set when one path is unsafe", () => {
|
||||
expect(
|
||||
relativeImportedFiles([
|
||||
{ path: "ok.txt", content: "a" },
|
||||
{ path: "../escape.txt", content: "b" },
|
||||
]),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it("accepts an export without files", () => {
|
||||
expect(relativeImportedFiles([])).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("importableRoutines", () => {
|
||||
it("leaves out one-shot schedules", () => {
|
||||
const routines = [
|
||||
{ name: "daily", crons: ["0 9 * * *"] },
|
||||
{ name: "once", crons: ["@once"] },
|
||||
{ name: "webhook", crons: [] },
|
||||
];
|
||||
expect(importableRoutines(routines).map((routine) => routine.name)).toEqual([
|
||||
"daily",
|
||||
"webhook",
|
||||
]);
|
||||
});
|
||||
});
|
||||
34
apps/api/src/bot-import.ts
Normal file
34
apps/api/src/bot-import.ts
Normal file
@ -0,0 +1,34 @@
|
||||
import { isOneShotRoutineCrons } from "@rakazo/core";
|
||||
|
||||
/** A relative, forward-slash path without `.`/`..` segments, or null when it could escape. */
|
||||
export function safeImportPath(raw: string): string | null {
|
||||
const normalized = raw.replace(/\\/g, "/");
|
||||
if (normalized.startsWith("/") || /^[a-z]:/i.test(normalized)) return null;
|
||||
const segments = normalized.split("/").filter((segment) => segment !== "" && segment !== ".");
|
||||
if (segments.length === 0 || segments.includes("..")) return null;
|
||||
return segments.join("/");
|
||||
}
|
||||
|
||||
/**
|
||||
* Exports of bots on a Team Computer carry their workspace prefix (`bots/<old id>/…`).
|
||||
* The prefix names the old bot, so it is dropped and the importer writes under the new one.
|
||||
*/
|
||||
export function relativeImportedFiles<T extends { path: string }>(
|
||||
files: T[],
|
||||
): Array<T & { path: string }> | null {
|
||||
const safe: Array<T & { path: string }> = [];
|
||||
for (const file of files) {
|
||||
const path = safeImportPath(file.path);
|
||||
if (!path) return null;
|
||||
safe.push({ ...file, path });
|
||||
}
|
||||
const first = safe[0]?.path.split("/");
|
||||
const workspace = first && first.length > 2 && first[0] === "bots" ? `bots/${first[1]}/` : null;
|
||||
if (!workspace || !safe.every((file) => file.path.startsWith(workspace))) return safe;
|
||||
return safe.map((file) => ({ ...file, path: file.path.slice(workspace.length) }));
|
||||
}
|
||||
|
||||
/** One-shot schedules belong to the chat that set them, so they are not carried over. */
|
||||
export function importableRoutines<T extends { crons: string[] }>(routines: T[]): T[] {
|
||||
return routines.filter((routine) => !isOneShotRoutineCrons(routine.crons));
|
||||
}
|
||||
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", () => {
|
||||
expect(loadEnv(base).integrationsCatalogUrl).toBeUndefined();
|
||||
expect(
|
||||
|
||||
@ -8,7 +8,6 @@ import {
|
||||
resolveEncryptionKey,
|
||||
resolveScreenProxySecret,
|
||||
resolveSupervisorToken,
|
||||
resolveTradingBotOrigin,
|
||||
} from "@rakazo/core";
|
||||
|
||||
export { resolveCloudAgentProvider, resolveSandboxProvider } from "@rakazo/adapters";
|
||||
@ -93,8 +92,6 @@ export interface AppEnv {
|
||||
updaterToken: string | undefined;
|
||||
/** Current application image tag; used for compose manual-upgrade command selection. */
|
||||
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 {
|
||||
@ -182,7 +179,6 @@ export function loadEnv(source: NodeJS.ProcessEnv = process.env): AppEnv {
|
||||
updaterUrl,
|
||||
updaterToken,
|
||||
imageTag: optional(source.RAKAZO_IMAGE_TAG),
|
||||
tradingBotUrl: resolveTradingBotOrigin(source.TRADING_BOT_URL),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@ -1,8 +1,10 @@
|
||||
import { buildSuggestionCard, withSuggestionStatus } from "@rakazo/core";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
buildInboxItems,
|
||||
type InboxMessageRow,
|
||||
type InboxRunRow,
|
||||
type InboxSuggestionRow,
|
||||
type InboxUnreadThreadRow,
|
||||
} from "./inbox.js";
|
||||
|
||||
@ -18,9 +20,11 @@ function run(
|
||||
status: "waiting_input" | "waiting_takeover",
|
||||
thread: Partial<InboxRunRow["thread"]> = {},
|
||||
bot = scout,
|
||||
spaceId = "space-home",
|
||||
): InboxRunRow {
|
||||
return {
|
||||
id,
|
||||
spaceId,
|
||||
status,
|
||||
updatedAt: at(0),
|
||||
bot,
|
||||
@ -48,8 +52,10 @@ const approval = {
|
||||
function unreadThread(
|
||||
bot: InboxUnreadThreadRow["bot"],
|
||||
latest?: { id: string; text: string; minute: number },
|
||||
spaceId = "space-home",
|
||||
): InboxUnreadThreadRow {
|
||||
return {
|
||||
spaceId,
|
||||
createdAt: at(0),
|
||||
bot,
|
||||
messages: latest
|
||||
@ -227,4 +233,105 @@ describe("buildInboxItems", () => {
|
||||
});
|
||||
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 } });
|
||||
});
|
||||
it("lists an open suggestion with its card, and drops it once settled", () => {
|
||||
const card = buildSuggestionCard({
|
||||
id: "effect-9",
|
||||
effectId: "effect-9",
|
||||
tool: "schedule_create",
|
||||
args: { name: "Morning walk", prompt: "Take a walk." },
|
||||
});
|
||||
const row = (blocks: unknown[]): InboxSuggestionRow => ({
|
||||
spaceId: "space-home",
|
||||
createdAt: at(3),
|
||||
bot: scout,
|
||||
thread: { botId: scout.id, groupId: null, group: null },
|
||||
messageId: "msg-card",
|
||||
cardId: "effect-9",
|
||||
blocks,
|
||||
});
|
||||
const items = buildInboxItems({
|
||||
runs: [],
|
||||
messages: [],
|
||||
unreadThreads: [unreadThread(scout, { id: "msg-card", text: "", minute: 3 })],
|
||||
suggestions: [row([card])],
|
||||
});
|
||||
expect(items).toEqual([
|
||||
{
|
||||
kind: "suggestion",
|
||||
bot: scout,
|
||||
target: { botId: scout.id },
|
||||
messageId: "msg-card",
|
||||
card,
|
||||
createdAt: at(3).toISOString(),
|
||||
},
|
||||
]);
|
||||
expect(
|
||||
buildInboxItems({
|
||||
runs: [],
|
||||
messages: [],
|
||||
unreadThreads: [],
|
||||
suggestions: [row([withSuggestionStatus(card, "dismissed")])],
|
||||
}),
|
||||
).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
@ -1,20 +1,23 @@
|
||||
import type { Actor, InboxItem, InboxThreadTarget } from "@rakazo/contracts";
|
||||
import { MessageBlock } from "@rakazo/contracts";
|
||||
import { MessageBlock, SUGGESTION_TTL_MS } from "@rakazo/contracts";
|
||||
import { isApprovalAskBlock } from "@rakazo/core";
|
||||
import type { PrismaClient } from "@rakazo/db";
|
||||
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_UNREAD_LIMIT = 50;
|
||||
const INBOX_PREVIEW_LENGTH = 280;
|
||||
|
||||
type AskBlock = Extract<MessageBlock, { kind: "ask" }>;
|
||||
type CardBlock = Extract<MessageBlock, { kind: "card" }>;
|
||||
type ComputerBlock = Extract<MessageBlock, { kind: "computer" }>;
|
||||
type InboxBot = { id: string; name: string; color: string };
|
||||
type InboxSpace = NonNullable<InboxItem["space"]>;
|
||||
|
||||
export interface InboxRunRow {
|
||||
id: string;
|
||||
spaceId: string;
|
||||
status: string;
|
||||
updatedAt: Date;
|
||||
bot: InboxBot;
|
||||
@ -33,12 +36,25 @@ export interface InboxMessageRow {
|
||||
}
|
||||
|
||||
export interface InboxUnreadThreadRow {
|
||||
spaceId: string;
|
||||
createdAt: Date;
|
||||
bot: InboxBot | null;
|
||||
/** The newest bot message, if any. */
|
||||
messages: Array<Omit<InboxMessageRow, "runId">>;
|
||||
}
|
||||
|
||||
/** An open suggestion: the card holding it and the thread it was posted in. */
|
||||
export interface InboxSuggestionRow {
|
||||
spaceId: string;
|
||||
createdAt: Date;
|
||||
bot: InboxBot;
|
||||
thread: InboxRunRow["thread"];
|
||||
messageId: string;
|
||||
cardId: string;
|
||||
/** The card message's blocks. */
|
||||
blocks: unknown;
|
||||
}
|
||||
|
||||
function parseBlocks(blocks: unknown): MessageBlock[] {
|
||||
const parsed = MessageBlock.array().safeParse(blocks);
|
||||
return parsed.success ? parsed.data : [];
|
||||
@ -73,12 +89,14 @@ function previewText(blocks: unknown): string {
|
||||
}
|
||||
|
||||
/**
|
||||
* Where an ask can be answered from. The main conversation already shows its own asks, and an
|
||||
* archived group or an external conversation has no thread target to answer through.
|
||||
* Where an ask can be answered from. The open space's main conversation already shows its own
|
||||
* 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.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 thread.botId ? { botId: thread.botId } : null;
|
||||
@ -93,16 +111,25 @@ 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, open suggestions and unread threads into
|
||||
* inbox items:
|
||||
* 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: {
|
||||
runs: InboxRunRow[];
|
||||
/** Ask and takeover messages of those runs, newest first. */
|
||||
messages: InboxMessageRow[];
|
||||
unreadThreads: InboxUnreadThreadRow[];
|
||||
/** Open suggestions, wherever their card was posted. */
|
||||
suggestions?: InboxSuggestionRow[];
|
||||
/** The user's other spaces by id; rows of any space not listed belong to the current one. */
|
||||
otherSpaces?: ReadonlyMap<string, InboxSpace>;
|
||||
}): InboxItem[] {
|
||||
const spaceOf = (spaceId: string) => {
|
||||
const space = input.otherSpaces?.get(spaceId);
|
||||
return space ? { space } : {};
|
||||
};
|
||||
const messagesByRun = new Map<string, InboxMessageRow[]>();
|
||||
for (const message of input.messages) {
|
||||
if (!message.runId) continue;
|
||||
@ -115,13 +142,14 @@ export function buildInboxItems(input: {
|
||||
for (const run of input.runs) {
|
||||
const messages = messagesByRun.get(run.id) ?? [];
|
||||
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;
|
||||
if (!target || !pending) continue;
|
||||
waiting.push({
|
||||
kind: isApprovalAskBlock(pending.block) ? "approval" : "ask",
|
||||
bot: run.bot,
|
||||
target,
|
||||
...spaceOf(run.spaceId),
|
||||
runId: run.id,
|
||||
messageId: pending.message.id,
|
||||
ask: pending.block,
|
||||
@ -135,6 +163,7 @@ export function buildInboxItems(input: {
|
||||
kind: "takeover",
|
||||
bot: run.bot,
|
||||
target: { botId: run.bot.id },
|
||||
...spaceOf(run.spaceId),
|
||||
runId: run.id,
|
||||
messageId: request?.message.id ?? null,
|
||||
text: request?.block.text ?? "",
|
||||
@ -143,6 +172,23 @@ export function buildInboxItems(input: {
|
||||
}
|
||||
}
|
||||
|
||||
for (const suggestion of input.suggestions ?? []) {
|
||||
const target = askTarget(suggestion.thread, !input.otherSpaces?.has(suggestion.spaceId));
|
||||
const card = parseBlocks(suggestion.blocks).find(
|
||||
(block): block is CardBlock => block.kind === "card" && block.id === suggestion.cardId,
|
||||
);
|
||||
if (!target || card?.suggestion?.status !== "proposed") continue;
|
||||
waiting.push({
|
||||
kind: "suggestion",
|
||||
bot: suggestion.bot,
|
||||
target,
|
||||
...spaceOf(suggestion.spaceId),
|
||||
messageId: suggestion.messageId,
|
||||
card,
|
||||
createdAt: suggestion.createdAt.toISOString(),
|
||||
});
|
||||
}
|
||||
|
||||
const covered = new Set(waiting.map((item) => targetKey(item.target)));
|
||||
const unread: InboxItem[] = [];
|
||||
for (const thread of input.unreadThreads) {
|
||||
@ -154,6 +200,7 @@ export function buildInboxItems(input: {
|
||||
kind: "unread",
|
||||
bot: thread.bot,
|
||||
target,
|
||||
...spaceOf(thread.spaceId),
|
||||
messageId: latest?.id ?? null,
|
||||
text: previewText(latest?.blocks),
|
||||
createdAt: (latest?.createdAt ?? thread.createdAt).toISOString(),
|
||||
@ -165,13 +212,39 @@ export function buildInboxItems(input: {
|
||||
|
||||
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[]> {
|
||||
const otherSpaces = await otherInboxSpaces(prisma, actor);
|
||||
const spaceIds = [actor.spaceId, ...otherSpaces.keys()];
|
||||
const ownBot = { userId: actor.userId, archivedAt: null };
|
||||
const [runs, unreadThreads] = await Promise.all([
|
||||
const threadSelect = {
|
||||
botId: true,
|
||||
groupId: true,
|
||||
group: { select: { kind: true, archivedAt: true } },
|
||||
} as const;
|
||||
const [runs, unreadThreads, suggestionEffects] = await Promise.all([
|
||||
prisma.run.findMany({
|
||||
where: {
|
||||
spaceId: actor.spaceId,
|
||||
spaceId: { in: spaceIds },
|
||||
userId: actor.userId,
|
||||
status: { in: ["waiting_input", "waiting_takeover"] },
|
||||
bot: ownBot,
|
||||
@ -179,17 +252,12 @@ export async function listInbox(prisma: PrismaClient, actor: Actor): Promise<Inb
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
spaceId: true,
|
||||
status: true,
|
||||
threadId: true,
|
||||
updatedAt: true,
|
||||
bot: { select: botSelect },
|
||||
thread: {
|
||||
select: {
|
||||
botId: true,
|
||||
groupId: true,
|
||||
group: { select: { kind: true, archivedAt: true } },
|
||||
},
|
||||
},
|
||||
thread: { select: threadSelect },
|
||||
},
|
||||
orderBy: [{ updatedAt: "asc" }, { id: "asc" }],
|
||||
take: INBOX_RUN_LIMIT,
|
||||
@ -197,12 +265,13 @@ export async function listInbox(prisma: PrismaClient, actor: Actor): Promise<Inb
|
||||
// Only specialists' own threads; group threads, the main conversation included, stay out.
|
||||
prisma.thread.findMany({
|
||||
where: {
|
||||
spaceId: actor.spaceId,
|
||||
spaceId: { in: spaceIds },
|
||||
userId: actor.userId,
|
||||
unread: true,
|
||||
bot: { ...ownBot, kind: "specialist" },
|
||||
},
|
||||
select: {
|
||||
spaceId: true,
|
||||
createdAt: true,
|
||||
bot: { select: botSelect },
|
||||
messages: {
|
||||
@ -215,7 +284,54 @@ export async function listInbox(prisma: PrismaClient, actor: Actor): Promise<Inb
|
||||
orderBy: [{ createdAt: "asc" }, { id: "asc" }],
|
||||
take: INBOX_UNREAD_LIMIT,
|
||||
}),
|
||||
prisma.externalEffect.findMany({
|
||||
where: {
|
||||
spaceId: { in: spaceIds },
|
||||
kind: "suggestion",
|
||||
status: "proposed",
|
||||
createdAt: { gt: new Date(Date.now() - SUGGESTION_TTL_MS) },
|
||||
run: { userId: actor.userId, bot: ownBot, thread: { userId: actor.userId } },
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
spaceId: true,
|
||||
createdAt: true,
|
||||
request: true,
|
||||
run: { select: { bot: { select: botSelect }, thread: { select: threadSelect } } },
|
||||
},
|
||||
orderBy: [{ createdAt: "asc" }, { id: "asc" }],
|
||||
take: INBOX_RUN_LIMIT,
|
||||
}),
|
||||
]);
|
||||
const cardMessageIds = new Map<string, string>();
|
||||
for (const effect of suggestionEffects) {
|
||||
const messageId = (effect.request as { card?: { messageId?: unknown } } | null)?.card
|
||||
?.messageId;
|
||||
if (typeof messageId === "string") cardMessageIds.set(effect.id, messageId);
|
||||
}
|
||||
const cardMessages =
|
||||
cardMessageIds.size === 0
|
||||
? []
|
||||
: await prisma.message.findMany({
|
||||
where: { id: { in: [...cardMessageIds.values()] }, role: "bot" },
|
||||
select: { id: true, blocks: true },
|
||||
});
|
||||
const blocksById = new Map(cardMessages.map((message) => [message.id, message.blocks]));
|
||||
const suggestions: InboxSuggestionRow[] = suggestionEffects.flatMap((effect) => {
|
||||
const messageId = cardMessageIds.get(effect.id);
|
||||
if (!messageId || !blocksById.has(messageId)) return [];
|
||||
return [
|
||||
{
|
||||
spaceId: effect.spaceId,
|
||||
createdAt: effect.createdAt,
|
||||
bot: effect.run.bot,
|
||||
thread: effect.run.thread,
|
||||
messageId,
|
||||
cardId: effect.id,
|
||||
blocks: blocksById.get(messageId),
|
||||
},
|
||||
];
|
||||
});
|
||||
const messages =
|
||||
runs.length === 0
|
||||
? []
|
||||
@ -232,5 +348,5 @@ export async function listInbox(prisma: PrismaClient, actor: Actor): Promise<Inb
|
||||
orderBy: { seq: "desc" },
|
||||
select: { id: true, runId: true, blocks: true, createdAt: true },
|
||||
});
|
||||
return buildInboxItems({ runs, messages, unreadThreads });
|
||||
return buildInboxItems({ runs, messages, unreadThreads, suggestions, otherSpaces });
|
||||
}
|
||||
|
||||
@ -7,6 +7,7 @@ import { serve } from "@hono/node-server";
|
||||
import { SERVICE_NAMES } from "@rakazo/logging";
|
||||
import { createRootLogger } from "@rakazo/logging/axiom";
|
||||
import { createApp } from "./app.js";
|
||||
import { API_SERVER_OPTIONS } from "./artifact-http.js";
|
||||
import { loadEnv } from "./env.js";
|
||||
|
||||
const logger = createRootLogger(SERVICE_NAMES.api);
|
||||
@ -14,9 +15,17 @@ const logger = createRootLogger(SERVICE_NAMES.api);
|
||||
try {
|
||||
const env = loadEnv();
|
||||
const { app, stop } = await createApp({ ...env, logger });
|
||||
const server = serve({ fetch: app.fetch, port: env.port, hostname: env.apiHost }, () => {
|
||||
logger.info("api listening", { "http.host": env.apiHost, "http.port": env.port });
|
||||
});
|
||||
const server = serve(
|
||||
{
|
||||
fetch: app.fetch,
|
||||
port: env.port,
|
||||
hostname: env.apiHost,
|
||||
serverOptions: API_SERVER_OPTIONS,
|
||||
},
|
||||
() => {
|
||||
logger.info("api listening", { "http.host": env.apiHost, "http.port": env.port });
|
||||
},
|
||||
);
|
||||
|
||||
// Long-lived connections (threads.subscribe SSE streams) never end on their
|
||||
// own, so server.close() alone waits forever for them. Track sockets and
|
||||
|
||||
@ -43,13 +43,20 @@ export async function assertNotMainConversation(
|
||||
if (group?.kind === "main") throw new ORPCError("BAD_REQUEST", { message });
|
||||
}
|
||||
|
||||
/** The main conversation carries the main bot's name. */
|
||||
export async function renameMainConversation(prisma: PrismaClient, userId: string, name: string) {
|
||||
await prisma.chatGroup.updateMany({ where: { userId, kind: "main" }, data: { name } });
|
||||
/** A main conversation carries its main bot's name. */
|
||||
export async function renameMainConversation(
|
||||
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.
|
||||
*/
|
||||
export async function joinAddressedBots(
|
||||
@ -71,5 +78,9 @@ export async function joinAddressedBots(
|
||||
.map((bot) => bot.id);
|
||||
if (addressed.length === 0) return;
|
||||
// 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,
|
||||
});
|
||||
}
|
||||
|
||||
34
apps/api/src/media-capabilities.test.ts
Normal file
34
apps/api/src/media-capabilities.test.ts
Normal file
@ -0,0 +1,34 @@
|
||||
import type { MediaCapabilities } from "@rakazo/adapter-kit";
|
||||
import type { Actor } from "@rakazo/contracts";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { MediaRouteDeps } from "./media-jobs.js";
|
||||
import { getMediaCapabilities } from "./media-jobs.js";
|
||||
|
||||
const actor: Actor = {
|
||||
userId: "user-1",
|
||||
spaceId: "space-1",
|
||||
email: "user@rakazo.test",
|
||||
isDeploymentOwner: false,
|
||||
};
|
||||
|
||||
describe("media capabilities", () => {
|
||||
it("answers for the actor's space", async () => {
|
||||
const asked: string[] = [];
|
||||
const deps = {
|
||||
media: {
|
||||
capabilities: async (spaceId: string) => {
|
||||
asked.push(spaceId);
|
||||
return { transcribe: true, export_video: false } as MediaCapabilities;
|
||||
},
|
||||
},
|
||||
} as unknown as MediaRouteDeps;
|
||||
await expect(getMediaCapabilities(deps, actor)).resolves.toMatchObject({ transcribe: true });
|
||||
expect(asked).toEqual(["space-1"]);
|
||||
});
|
||||
|
||||
it("reports nothing available without a media runtime", async () => {
|
||||
const capabilities = await getMediaCapabilities({} as MediaRouteDeps, actor);
|
||||
expect(Object.values(capabilities)).toHaveLength(6);
|
||||
expect(Object.values(capabilities).every((value) => value === false)).toBe(true);
|
||||
});
|
||||
});
|
||||
165
apps/api/src/media-jobs.ts
Normal file
165
apps/api/src/media-jobs.ts
Normal file
@ -0,0 +1,165 @@
|
||||
import { ORPCError } from "@orpc/server";
|
||||
import type { MediaCapabilities, MediaRuntime } from "@rakazo/adapter-kit";
|
||||
import type { EncryptedSecretStore } from "@rakazo/adapters";
|
||||
import {
|
||||
loadMediaSettings,
|
||||
MediaJobRejectedError,
|
||||
MediaSettingsInvalidError,
|
||||
mediaSettingsView,
|
||||
saveMediaSettings,
|
||||
} from "@rakazo/adapters";
|
||||
import type { Actor, MediaJobStartInput, MediaSettingsUpdate } from "@rakazo/contracts";
|
||||
import { MediaOperationSchema } from "@rakazo/contracts";
|
||||
import type { PrismaClient } from "@rakazo/db";
|
||||
import { IsolationError, requireMembership } from "@rakazo/db";
|
||||
import { resolveThreadTarget } from "./thread-target.js";
|
||||
|
||||
export interface MediaRouteDeps {
|
||||
prisma: PrismaClient;
|
||||
secrets: EncryptedSecretStore;
|
||||
media?: MediaRuntime;
|
||||
}
|
||||
|
||||
/** Media backends run on the deployment owner's own server, so only they configure them. */
|
||||
function requireDeploymentOwner(actor: Actor) {
|
||||
if (!actor.isDeploymentOwner) throw new ORPCError("FORBIDDEN");
|
||||
}
|
||||
|
||||
function requireMedia(deps: MediaRouteDeps): MediaRuntime {
|
||||
if (!deps.media) throw new ORPCError("NOT_IMPLEMENTED");
|
||||
return deps.media;
|
||||
}
|
||||
|
||||
export async function getMediaSettings(deps: MediaRouteDeps, actor: Actor) {
|
||||
requireDeploymentOwner(actor);
|
||||
return mediaSettingsView(await loadMediaSettings(deps));
|
||||
}
|
||||
|
||||
export async function updateMediaSettings(
|
||||
deps: MediaRouteDeps,
|
||||
actor: Actor,
|
||||
input: MediaSettingsUpdate,
|
||||
) {
|
||||
requireDeploymentOwner(actor);
|
||||
try {
|
||||
return await saveMediaSettings(deps, input);
|
||||
} catch (error) {
|
||||
if (error instanceof MediaSettingsInvalidError) {
|
||||
throw new ORPCError("BAD_REQUEST", { message: error.message });
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The actor in the space a card lives in. A card is acted on where it is shown, which need not be
|
||||
* the space the client has selected (the main conversation lives in the home space), so the
|
||||
* space comes from the card's own record: it must belong to the actor, and the actor must still
|
||||
* be a member of that space. Anything else reads as not found.
|
||||
*/
|
||||
async function actorInSpace(deps: MediaRouteDeps, actor: Actor, spaceId: string): Promise<Actor> {
|
||||
if (spaceId === actor.spaceId) return actor;
|
||||
try {
|
||||
return await requireMembership(deps.prisma, actor.userId, spaceId);
|
||||
} catch (error) {
|
||||
if (error instanceof IsolationError) throw new ORPCError("NOT_FOUND");
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Starts an operation a user picked on a card (export, transcribe, edit) in the card's
|
||||
* conversation. The conversation is authorized like every thread procedure, in the card's space;
|
||||
* the runtime checks that the operation's source artifacts belong to the actor there, and the
|
||||
* job can only ever read the actor's artifacts of that space.
|
||||
*/
|
||||
export async function startMediaJob(deps: MediaRouteDeps, actor: Actor, input: MediaJobStartInput) {
|
||||
const media = requireMedia(deps);
|
||||
const source = await deps.prisma.message.findFirst({
|
||||
where: {
|
||||
id: input.messageId,
|
||||
thread: {
|
||||
userId: actor.userId,
|
||||
...(input.botId ? { botId: input.botId } : { groupId: input.groupId }),
|
||||
},
|
||||
},
|
||||
select: { botId: true, thread: { select: { spaceId: true } } },
|
||||
});
|
||||
if (!source) throw new ORPCError("NOT_FOUND");
|
||||
const scoped = await actorInSpace(deps, actor, source.thread.spaceId);
|
||||
const target = await resolveThreadTarget(deps.prisma, scoped, input).catch((error) => {
|
||||
if (error instanceof IsolationError) throw new ORPCError("NOT_FOUND");
|
||||
throw error;
|
||||
});
|
||||
const bots =
|
||||
target.kind === "bot"
|
||||
? [target.botId]
|
||||
: [
|
||||
...(target.leadBotId && target.memberBotIds.includes(target.leadBotId)
|
||||
? [target.leadBotId]
|
||||
: []),
|
||||
...target.memberBotIds,
|
||||
];
|
||||
const botId = source.botId && bots.includes(source.botId) ? source.botId : bots[0];
|
||||
if (!botId) throw new ORPCError("NOT_FOUND");
|
||||
try {
|
||||
return await media.start({
|
||||
operation: input.operation,
|
||||
input: input.input,
|
||||
spaceId: scoped.spaceId,
|
||||
userId: scoped.userId,
|
||||
botId,
|
||||
groupId: target.kind === "group" ? target.groupId : null,
|
||||
threadId: target.threadId,
|
||||
runId: null,
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof MediaJobRejectedError) {
|
||||
throw new ORPCError("BAD_REQUEST", { message: error.message });
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Booleans only: which operations a space can run, never how they are configured. With a job,
|
||||
* the space is the job's (a card's follow-ups); otherwise the actor's current space.
|
||||
*/
|
||||
export async function getMediaCapabilities(
|
||||
deps: MediaRouteDeps,
|
||||
actor: Actor,
|
||||
input: { jobId?: string } = {},
|
||||
): Promise<MediaCapabilities> {
|
||||
const spaceId = input.jobId ? (await ownedJob(deps, actor, input.jobId)).spaceId : actor.spaceId;
|
||||
if (deps.media) return deps.media.capabilities(spaceId);
|
||||
return Object.fromEntries(
|
||||
MediaOperationSchema.options.map((operation) => [operation, false]),
|
||||
) as MediaCapabilities;
|
||||
}
|
||||
|
||||
/** The actor's job, in whichever of the actor's spaces it runs. */
|
||||
async function ownedJob(deps: MediaRouteDeps, actor: Actor, jobId: string) {
|
||||
const job = await deps.prisma.mediaJob.findFirst({
|
||||
where: { id: jobId, userId: actor.userId },
|
||||
select: { id: true, spaceId: true },
|
||||
});
|
||||
if (!job) throw new ORPCError("NOT_FOUND");
|
||||
await actorInSpace(deps, actor, job.spaceId);
|
||||
return job;
|
||||
}
|
||||
|
||||
export async function getMediaJob(deps: MediaRouteDeps, actor: Actor, jobId: string) {
|
||||
const media = requireMedia(deps);
|
||||
const snapshot = await media.get((await ownedJob(deps, actor, jobId)).id);
|
||||
if (!snapshot) throw new ORPCError("NOT_FOUND");
|
||||
return snapshot;
|
||||
}
|
||||
|
||||
export async function cancelMediaJob(deps: MediaRouteDeps, actor: Actor, jobId: string) {
|
||||
const media = requireMedia(deps);
|
||||
const { id } = await ownedJob(deps, actor, jobId);
|
||||
await media.cancel(id);
|
||||
const snapshot = await media.get(id);
|
||||
if (!snapshot) throw new ORPCError("NOT_FOUND");
|
||||
return snapshot;
|
||||
}
|
||||
82
apps/api/src/media-library.ts
Normal file
82
apps/api/src/media-library.ts
Normal file
@ -0,0 +1,82 @@
|
||||
import path from "node:path";
|
||||
import { ORPCError } from "@orpc/server";
|
||||
import type { JobPublisher } from "@rakazo/adapter-kit";
|
||||
import { mediaLibrarySyncJob } from "@rakazo/adapter-kit";
|
||||
import { checkMediaLibraryFolder, mediaLibrarySourceView } from "@rakazo/adapters";
|
||||
import type { Actor, MediaLibrarySourceAddInput } from "@rakazo/contracts";
|
||||
import type { PrismaClient } from "@rakazo/db";
|
||||
|
||||
export interface MediaLibraryRouteDeps {
|
||||
prisma: PrismaClient;
|
||||
jobs: JobPublisher;
|
||||
}
|
||||
|
||||
/** Library folders are paths on the server, so only its owner adds them, like host folders. */
|
||||
function requireDeploymentOwner(actor: Actor) {
|
||||
if (!actor.isDeploymentOwner) throw new ORPCError("FORBIDDEN");
|
||||
}
|
||||
|
||||
async function ownSource(deps: MediaLibraryRouteDeps, actor: Actor, sourceId: string) {
|
||||
const source = await deps.prisma.mediaLibrarySource.findFirst({
|
||||
where: { id: sourceId, userId: actor.userId },
|
||||
});
|
||||
if (!source) throw new ORPCError("NOT_FOUND");
|
||||
return source;
|
||||
}
|
||||
|
||||
export async function listMediaLibrarySources(deps: MediaLibraryRouteDeps, actor: Actor) {
|
||||
requireDeploymentOwner(actor);
|
||||
const rows = await deps.prisma.mediaLibrarySource.findMany({
|
||||
where: { userId: actor.userId },
|
||||
orderBy: { createdAt: "asc" },
|
||||
});
|
||||
return rows.map(mediaLibrarySourceView);
|
||||
}
|
||||
|
||||
export async function addMediaLibrarySource(
|
||||
deps: MediaLibraryRouteDeps,
|
||||
actor: Actor,
|
||||
input: MediaLibrarySourceAddInput,
|
||||
) {
|
||||
requireDeploymentOwner(actor);
|
||||
const problem = await checkMediaLibraryFolder(input.path);
|
||||
if (problem) throw new ORPCError("BAD_REQUEST", { message: problem });
|
||||
const folder = path.resolve(input.path);
|
||||
const existing = await deps.prisma.mediaLibrarySource.findUnique({
|
||||
where: { userId_path: { userId: actor.userId, path: folder } },
|
||||
});
|
||||
if (existing) return mediaLibrarySourceView(existing);
|
||||
const source = await deps.prisma.mediaLibrarySource.create({
|
||||
data: { userId: actor.userId, path: folder, status: "syncing" },
|
||||
});
|
||||
await deps.jobs.enqueue(mediaLibrarySyncJob(source.id));
|
||||
return mediaLibrarySourceView(source);
|
||||
}
|
||||
|
||||
export async function removeMediaLibrarySource(
|
||||
deps: MediaLibraryRouteDeps,
|
||||
actor: Actor,
|
||||
sourceId: string,
|
||||
) {
|
||||
requireDeploymentOwner(actor);
|
||||
await ownSource(deps, actor, sourceId);
|
||||
await deps.prisma.mediaLibrarySource.deleteMany({
|
||||
where: { id: sourceId, userId: actor.userId },
|
||||
});
|
||||
return { ok: true as const };
|
||||
}
|
||||
|
||||
export async function syncMediaLibrarySourceNow(
|
||||
deps: MediaLibraryRouteDeps,
|
||||
actor: Actor,
|
||||
sourceId: string,
|
||||
) {
|
||||
requireDeploymentOwner(actor);
|
||||
await ownSource(deps, actor, sourceId);
|
||||
const source = await deps.prisma.mediaLibrarySource.update({
|
||||
where: { id: sourceId },
|
||||
data: { status: "syncing", error: null },
|
||||
});
|
||||
await deps.jobs.enqueue(mediaLibrarySyncJob(sourceId));
|
||||
return mediaLibrarySourceView(source);
|
||||
}
|
||||
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 { join } from "node:path";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { getScanStatus, startOnboardingScan } from "./onboarding-scan.js";
|
||||
import path from "node:path";
|
||||
import type { PrismaClient } from "@rakazo/db";
|
||||
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 member = { userId: "user-member", isDeploymentOwner: false };
|
||||
const owner = { userId: "user-owner", spaceId: "space-owner", isDeploymentOwner: true };
|
||||
const member = { userId: "user-member", spaceId: "space-member", isDeploymentOwner: false };
|
||||
const roots: string[] = [];
|
||||
|
||||
async function scanHomeWithOneFile(): Promise<string> {
|
||||
const home = await mkdtemp(join(tmpdir(), "evoo-scan-"));
|
||||
await mkdir(join(home, "Projects"));
|
||||
await writeFile(join(home, "Projects", "notes.ts"), "export const x = 1;\n");
|
||||
return home;
|
||||
/** Fails the test if the scan writes anything before its checks pass. */
|
||||
const untouchedPrisma = new Proxy(
|
||||
{},
|
||||
{
|
||||
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) {
|
||||
return await vi.waitFor(
|
||||
async () => {
|
||||
const status = await getScanStatus(scanId, userId);
|
||||
if (status.status !== "complete" && status.status !== "failed") throw new Error("pending");
|
||||
() => {
|
||||
const status = onboardingScanStatus(scanId, userId);
|
||||
if (status.status === "scanning") throw new Error("pending");
|
||||
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", () => {
|
||||
afterEach(() => {
|
||||
vi.unstubAllEnvs();
|
||||
it("lists git projects with their languages and tools and never follows a symlink", async () => {
|
||||
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 () => {
|
||||
const { scanId } = await startOnboardingScan({ actor: member }, {});
|
||||
await expect(getScanStatus(scanId, owner.userId)).rejects.toThrow(/Scan not found/);
|
||||
await expect(getScanStatus(scanId, member.userId)).resolves.toMatchObject({
|
||||
status: expect.any(String),
|
||||
});
|
||||
await expect(getScanStatus("scan_missing", member.userId)).rejects.toThrow(/Scan not found/);
|
||||
it("offers a scan folder only to the deployment owner and only when it exists", async () => {
|
||||
const home = await tempDir();
|
||||
await files(home, ["file.txt"]);
|
||||
await expect(onboardingScanFolder(member, home)).resolves.toBeNull();
|
||||
await expect(onboardingScanFolder(owner, undefined)).resolves.toBeNull();
|
||||
await expect(onboardingScanFolder(owner, path.join(home, "missing"))).resolves.toBeNull();
|
||||
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 () => {
|
||||
const { scanId } = await startOnboardingScan({ actor: owner }, {});
|
||||
it("keeps a scan with the account that started it and returns only relative paths", async () => {
|
||||
const home = await tempDir();
|
||||
await project(home, "code/atlas", ["index.ts"]);
|
||||
const { scanId } = await startOnboardingScan(owner, home);
|
||||
|
||||
const status = await completed(scanId, owner.userId);
|
||||
expect(status.status).toBe("complete");
|
||||
expect(status.result).toMatchObject({ localRepos: [], fileSummary: { totalFiles: 0 } });
|
||||
expect(status).toEqual({
|
||||
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 () => {
|
||||
// The shared test setup pins the emulated runtime, which also keeps the scan simulated.
|
||||
vi.stubEnv("AGENT_RUNTIME", "");
|
||||
vi.stubEnv("SANDBOX_PROVIDER", "");
|
||||
const scanHome = await scanHomeWithOneFile();
|
||||
it("checks ownership, the folder and every chosen project again before writing", async () => {
|
||||
const home = await tempDir();
|
||||
const outside = await tempDir();
|
||||
await project(home, "code/atlas", ["index.ts"]);
|
||||
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 }, {});
|
||||
const memberStatus = await completed(asMember.scanId, member.userId);
|
||||
expect(memberStatus.result).toMatchObject({ fileSummary: { totalFiles: 0 } });
|
||||
await expect(
|
||||
applyOnboardingScan(untouchedPrisma, { ...owner, isDeploymentOwner: false }, home, choice),
|
||||
).rejects.toMatchObject({ code: "FORBIDDEN" });
|
||||
await expect(
|
||||
applyOnboardingScan(untouchedPrisma, owner, outside, choice),
|
||||
).rejects.toMatchObject({ code: "FORBIDDEN" });
|
||||
|
||||
const asOwner = await startOnboardingScan({ actor: owner, scanHome }, {});
|
||||
const ownerStatus = await completed(asOwner.scanId, owner.userId);
|
||||
expect(ownerStatus.status).toBe("complete");
|
||||
expect(ownerStatus.result).toMatchObject({ fileSummary: { totalFiles: 1 } });
|
||||
// The chosen project became a symlink out of the scan folder after the scan.
|
||||
await rm(path.join(home, "code/garden"), { recursive: true });
|
||||
await symlink(path.join(outside, "elsewhere"), path.join(home, "code/garden"));
|
||||
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 { describe, expect, it, vi } from "vitest";
|
||||
import { chooseFocus, markAppConnected } from "./onboarding.js";
|
||||
import { focusCard } from "./onboarding-intake.js";
|
||||
|
||||
const posted = vi.hoisted(() => [] as Array<{ blocks: unknown[] }>);
|
||||
vi.mock("@rakazo/db", async (original) => ({
|
||||
@ -13,16 +14,16 @@ vi.mock("@rakazo/db", async (original) => ({
|
||||
}));
|
||||
function fixture(catalog: unknown[]) {
|
||||
posted.length = 0;
|
||||
// A specialist's focus card, waiting for an answer.
|
||||
const pending = vi.fn(async () => [{ id: "choice", blocks: [focusCard()] }]);
|
||||
const tx = {
|
||||
$executeRaw: vi.fn(),
|
||||
message: {
|
||||
findMany: vi.fn(async () => [{ id: "choice", blocks: [{ kind: "choice", answerId: null }] }]),
|
||||
update: vi.fn(),
|
||||
},
|
||||
message: { findMany: pending, update: vi.fn() },
|
||||
};
|
||||
const deps = {
|
||||
prisma: {
|
||||
bot: { findFirst: vi.fn(async () => ({ id: "bot", thread: { id: "thread" } })) },
|
||||
message: { findMany: pending },
|
||||
$transaction: vi.fn(async (fn: (tx: unknown) => unknown) => fn(tx)),
|
||||
},
|
||||
events: { notify: vi.fn() },
|
||||
@ -39,17 +40,16 @@ function fixture(catalog: unknown[]) {
|
||||
describe("onboarding connection suggestions", () => {
|
||||
it("does not invent authorization cards when no connector has an app catalog", async () => {
|
||||
const { deps, actor } = fixture([]);
|
||||
await chooseFocus(deps, actor, "bot", "day");
|
||||
expect(posted.flatMap((message) => message.blocks)).not.toContainEqual(
|
||||
expect.objectContaining({ kind: "app_connect" }),
|
||||
);
|
||||
expect(posted.length).toBeGreaterThan(0);
|
||||
await chooseFocus(deps, actor, "bot", { optionId: "day" });
|
||||
// The answered card is all that changes; nothing is said around it.
|
||||
expect(posted).toEqual([]);
|
||||
expect(deps.events.notify).toHaveBeenCalled();
|
||||
});
|
||||
it("uses the available connector and omits unavailable apps", async () => {
|
||||
const { deps, actor } = fixture([
|
||||
{ connectorId: "pipedream", slug: "slack", name: "Slack", connected: false, logo: null },
|
||||
]);
|
||||
await chooseFocus(deps, actor, "bot", "day");
|
||||
await chooseFocus(deps, actor, "bot", { optionId: "day" });
|
||||
expect(
|
||||
posted
|
||||
.flatMap((message) => message.blocks)
|
||||
@ -57,6 +57,8 @@ describe("onboarding connection suggestions", () => {
|
||||
).toEqual([
|
||||
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 { 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 {
|
||||
appendEventInTransaction,
|
||||
createThreadMessageInTransaction,
|
||||
ensureMainConversation,
|
||||
IsolationError,
|
||||
isPersonalMainBot,
|
||||
type Prisma,
|
||||
type PrismaClient,
|
||||
type ThreadEvents,
|
||||
} 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
|
||||
* thread: greeting, a focus choice, and available app cards the user authorizes
|
||||
* inline. Focus must not rename the bot. No model tokens are spent. The main bot
|
||||
* talks in the main conversation, where onboarding lands.
|
||||
* Conversational onboarding, posted deterministically into a bot's thread; no model tokens are
|
||||
* spent. The main bot asks the onboarding questions (`onboarding-intake.ts`) in the main
|
||||
* conversation, one card after each answer. Any other bot gets one focus card that suggests
|
||||
* apps the user authorizes inline. Focus never renames a bot.
|
||||
*/
|
||||
|
||||
type OnboardingDeps = {
|
||||
prisma: PrismaClient;
|
||||
events: ThreadEvents;
|
||||
connectors: ConnectorRegistry;
|
||||
jobs: JobPublisher;
|
||||
};
|
||||
|
||||
type FocusOption = {
|
||||
id: string;
|
||||
letter: string;
|
||||
label: string;
|
||||
summary: string;
|
||||
apps: string[];
|
||||
};
|
||||
|
||||
const FOCUS_OPTIONS: FocusOption[] = [
|
||||
{
|
||||
id: "day",
|
||||
letter: "A",
|
||||
label: "Day-to-day work",
|
||||
summary: "Slack, calendar, and email",
|
||||
apps: ["slack", "gmail", "googlecalendar"],
|
||||
},
|
||||
{
|
||||
id: "inbox",
|
||||
letter: "B",
|
||||
label: "Inbox & email",
|
||||
summary: "email and calendar",
|
||||
apps: ["gmail", "googlecalendar", "slack"],
|
||||
},
|
||||
{
|
||||
id: "research",
|
||||
letter: "C",
|
||||
label: "Research & writing",
|
||||
summary: "the web, notes, and docs",
|
||||
apps: ["hackernews", "notion", "googledocs"],
|
||||
},
|
||||
{
|
||||
id: "crew",
|
||||
letter: "D",
|
||||
label: "Specialist crew",
|
||||
summary: "a coordinated team of focused Oos",
|
||||
apps: ["slack", "gmail", "googlecalendar"],
|
||||
},
|
||||
];
|
||||
type Target = { spaceId: string; botId: string; threadId: string };
|
||||
|
||||
const APP_DESCRIPTIONS: Record<string, string> = {
|
||||
slack: "Search, read, and send messages.",
|
||||
@ -79,80 +59,108 @@ async function requireBotThread(deps: OnboardingDeps, actor: Actor, botId: strin
|
||||
});
|
||||
if (!bot?.thread) throw new IsolationError();
|
||||
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: bot.thread };
|
||||
}
|
||||
|
||||
async function post(
|
||||
deps: OnboardingDeps,
|
||||
target: { spaceId: string; botId: string; threadId: string },
|
||||
/**
|
||||
* Serializes card writes on a thread. NO KEY UPDATE still lets an answer's effects insert rows
|
||||
* 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[],
|
||||
): Promise<string> {
|
||||
const committed = await deps.prisma.$transaction(async (tx: Prisma.TransactionClient) => {
|
||||
const message = await createThreadMessageInTransaction(tx, {
|
||||
threadId: target.threadId,
|
||||
role: "bot",
|
||||
blocks,
|
||||
botId: target.botId,
|
||||
});
|
||||
const event = await appendEventInTransaction(tx, {
|
||||
spaceId: target.spaceId,
|
||||
threadId: target.threadId,
|
||||
botId: target.botId,
|
||||
type: "thread.message.created",
|
||||
payload: { messageId: message.id, role: "bot", blocks },
|
||||
});
|
||||
return { message, event };
|
||||
): Promise<{ seq: number }> {
|
||||
const message = await createThreadMessageInTransaction(tx, {
|
||||
threadId: target.threadId,
|
||||
role: "bot",
|
||||
blocks,
|
||||
botId: target.botId,
|
||||
});
|
||||
return appendEventInTransaction(tx, {
|
||||
spaceId: target.spaceId,
|
||||
threadId: target.threadId,
|
||||
botId: target.botId,
|
||||
type: "thread.message.created",
|
||||
payload: { messageId: message.id, role: "bot", blocks },
|
||||
});
|
||||
}
|
||||
|
||||
async function updateBlocksInTransaction(
|
||||
tx: Prisma.TransactionClient,
|
||||
target: Target,
|
||||
messageId: string,
|
||||
blocks: MessageBlock[],
|
||||
): Promise<{ seq: number }> {
|
||||
await tx.message.update({ where: { id: messageId }, data: { blocks } });
|
||||
return appendEventInTransaction(tx, {
|
||||
spaceId: target.spaceId,
|
||||
threadId: target.threadId,
|
||||
botId: target.botId,
|
||||
type: "thread.message.updated",
|
||||
payload: { messageId, role: "bot", blocks },
|
||||
});
|
||||
await deps.events.notify(target.threadId, committed.event.seq);
|
||||
return committed.message.id;
|
||||
}
|
||||
|
||||
async function updateBlocks(
|
||||
deps: OnboardingDeps,
|
||||
target: { spaceId: string; botId: string; threadId: string },
|
||||
target: Target,
|
||||
messageId: string,
|
||||
blocks: MessageBlock[],
|
||||
): Promise<void> {
|
||||
const event = await deps.prisma.$transaction(async (tx: Prisma.TransactionClient) => {
|
||||
await tx.message.update({ where: { id: messageId }, data: { blocks } });
|
||||
return appendEventInTransaction(tx, {
|
||||
spaceId: target.spaceId,
|
||||
threadId: target.threadId,
|
||||
botId: target.botId,
|
||||
type: "thread.message.updated",
|
||||
payload: { messageId, role: "bot", blocks },
|
||||
});
|
||||
});
|
||||
const event = await deps.prisma.$transaction((tx: Prisma.TransactionClient) =>
|
||||
updateBlocksInTransaction(tx, target, messageId, blocks),
|
||||
);
|
||||
await deps.events.notify(target.threadId, event.seq);
|
||||
}
|
||||
|
||||
/** Sentinel answerId for a focus card the user dismissed without choosing. */
|
||||
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(
|
||||
deps: OnboardingDeps,
|
||||
actor: Actor,
|
||||
botId: string,
|
||||
): Promise<void> {
|
||||
const { thread } = await requireBotThread(deps, actor, botId);
|
||||
const existing = await deps.prisma.message.count({ where: { threadId: thread.id } });
|
||||
if (existing > 0) return;
|
||||
// Fresh chats start empty (same as web). The focus card is posted later via
|
||||
// promptFocus so non-first bots can wait ~10s for free typing, or skip if the
|
||||
// user already engaged.
|
||||
await requireBotThread(deps, actor, botId);
|
||||
}
|
||||
|
||||
function messageHasChoice(blocks: MessageBlock[]): boolean {
|
||||
return blocks.some((block) => block.kind === "choice");
|
||||
function isChoice(block: MessageBlock): block is ChoiceBlock {
|
||||
return block.kind === "choice";
|
||||
}
|
||||
|
||||
function messageHasPendingChoice(blocks: MessageBlock[]): boolean {
|
||||
return blocks.some((block) => block.kind === "choice" && !block.answerId);
|
||||
function pendingChoice(blocks: MessageBlock[]): ChoiceBlock | undefined {
|
||||
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(
|
||||
deps: OnboardingDeps,
|
||||
actor: Actor,
|
||||
@ -160,44 +168,32 @@ export async function promptFocus(
|
||||
): Promise<void> {
|
||||
const { bot, thread } = await requireBotThread(deps, actor, botId);
|
||||
const target = { spaceId: actor.spaceId, botId: bot.id, threadId: thread.id };
|
||||
const blocks: MessageBlock[] = [
|
||||
{
|
||||
kind: "choice",
|
||||
question: "What do you want me on first?",
|
||||
options: FOCUS_OPTIONS.map(({ id, letter, label }) => ({ id, letter, label })),
|
||||
},
|
||||
];
|
||||
// Check + insert + event in one transaction so concurrent promptFocus calls
|
||||
// cannot duplicate cards, and a concurrent user send cannot publish first.
|
||||
// The intake runs with the personal main agent; a project's main bot starts like any other.
|
||||
const main = await isPersonalMainBot(deps.prisma, bot);
|
||||
// Check + insert + event in one transaction so concurrent callers cannot duplicate cards,
|
||||
// and a concurrent user send cannot publish first.
|
||||
const committed = await deps.prisma.$transaction(async (tx: Prisma.TransactionClient) => {
|
||||
// Serialize concurrent promptFocus callers on this thread before the gate check.
|
||||
await tx.$executeRaw`SELECT id FROM threads WHERE id = ${thread.id} FOR UPDATE`;
|
||||
await lockThread(tx, thread.id);
|
||||
if (main && (await tx.userOnboarding.findUnique({ where: { userId: actor.userId } }))) {
|
||||
return null;
|
||||
}
|
||||
const recent = await tx.message.findMany({
|
||||
where: { threadId: thread.id },
|
||||
select: { role: true, blocks: true },
|
||||
orderBy: { createdAt: "asc" },
|
||||
});
|
||||
if (recent.some((message) => message.role === "user")) return null;
|
||||
if (recent.some((message) => messageHasChoice(message.blocks as MessageBlock[]))) return null;
|
||||
const message = await createThreadMessageInTransaction(tx, {
|
||||
threadId: target.threadId,
|
||||
role: "bot",
|
||||
blocks,
|
||||
botId: target.botId,
|
||||
});
|
||||
const event = await appendEventInTransaction(tx, {
|
||||
spaceId: target.spaceId,
|
||||
threadId: target.threadId,
|
||||
botId: target.botId,
|
||||
type: "thread.message.created",
|
||||
payload: { messageId: message.id, role: "bot", blocks },
|
||||
});
|
||||
return { message, event };
|
||||
if (!main && recent.some((message) => message.role === "user")) return null;
|
||||
if (recent.some((message) => (message.blocks as MessageBlock[]).some(isChoice))) return null;
|
||||
if (!main) return postInTransaction(tx, target, [focusCard()]);
|
||||
const first = intakeStep("focus");
|
||||
await tx.userOnboarding.create({ data: { userId: actor.userId, step: first.id } });
|
||||
return postInTransaction(tx, target, [
|
||||
first.card(await loadIntakeContext(tx, actor.userId, new Date())),
|
||||
]);
|
||||
});
|
||||
if (!committed) return;
|
||||
await deps.events.notify(target.threadId, committed.event.seq);
|
||||
if (committed) await deps.events.notify(target.threadId, committed.seq);
|
||||
}
|
||||
|
||||
/** Dismisses the pending card. An onboarding question counts as skipped, so the next follows. */
|
||||
export async function dismissFocus(
|
||||
deps: OnboardingDeps,
|
||||
actor: Actor,
|
||||
@ -205,81 +201,128 @@ export async function dismissFocus(
|
||||
): Promise<void> {
|
||||
const { bot, thread } = await requireBotThread(deps, actor, botId);
|
||||
const target = { spaceId: actor.spaceId, botId: bot.id, threadId: thread.id };
|
||||
const claimed = await deps.prisma.$transaction(async (tx: Prisma.TransactionClient) => {
|
||||
await tx.$executeRaw`SELECT id FROM threads WHERE id = ${thread.id} FOR UPDATE`;
|
||||
const recent = await tx.message.findMany({
|
||||
where: { threadId: thread.id },
|
||||
orderBy: { createdAt: "asc" },
|
||||
});
|
||||
const pending = recent.find((message) =>
|
||||
messageHasPendingChoice(message.blocks as MessageBlock[]),
|
||||
const pending = await findPendingCard(deps.prisma, thread.id);
|
||||
const skip = pending?.card.step ? secondaryOption(pending.card) : undefined;
|
||||
if (pending?.card.step && skip) {
|
||||
await chooseFocus(deps, actor, botId, { step: pending.card.step, optionId: skip.id });
|
||||
return;
|
||||
}
|
||||
const event = await deps.prisma.$transaction(async (tx: Prisma.TransactionClient) => {
|
||||
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;
|
||||
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 };
|
||||
return updateBlocksInTransaction(tx, target, current.messageId, blocks);
|
||||
});
|
||||
if (!claimed) return;
|
||||
await deps.events.notify(target.threadId, claimed.event.seq);
|
||||
if (event) await deps.events.notify(target.threadId, 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(
|
||||
deps: OnboardingDeps,
|
||||
actor: Actor,
|
||||
botId: string,
|
||||
optionId: string,
|
||||
input: ChooseInput,
|
||||
): Promise<void> {
|
||||
const option = FOCUS_OPTIONS.find((entry) => entry.id === optionId);
|
||||
if (!option) throw new IsolationError();
|
||||
const { bot, thread } = await requireBotThread(deps, actor, botId);
|
||||
const target = { spaceId: actor.spaceId, botId: bot.id, threadId: thread.id };
|
||||
const 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) => {
|
||||
await tx.$executeRaw`SELECT id FROM threads WHERE id = ${thread.id} FOR UPDATE`;
|
||||
const recent = await tx.message.findMany({
|
||||
where: { threadId: thread.id },
|
||||
orderBy: { createdAt: "asc" },
|
||||
});
|
||||
const pending = recent.find((message) =>
|
||||
messageHasPendingChoice(message.blocks as MessageBlock[]),
|
||||
);
|
||||
if (!pending) return null;
|
||||
const blocks = (pending.blocks as MessageBlock[]).map((block) =>
|
||||
block.kind === "choice" ? { ...block, answerId: option.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 };
|
||||
const event = await deps.prisma.$transaction(async (tx: Prisma.TransactionClient) => {
|
||||
await lockThread(tx, thread.id);
|
||||
const current = await findPendingCard(tx, thread.id);
|
||||
if (current?.messageId !== pending.messageId || current.card.step !== input.step) return null;
|
||||
const onboarding = current.card.step
|
||||
? await tx.userOnboarding.findUnique({ where: { userId: actor.userId } })
|
||||
: null;
|
||||
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,
|
||||
);
|
||||
}
|
||||
const answered = answeredCard(current.card, answer);
|
||||
const blocks = current.blocks.map((block) => (block === current.card ? answered : block));
|
||||
let last = await updateBlocksInTransaction(tx, target, current.messageId, blocks);
|
||||
for (const message of followUp) last = await postInTransaction(tx, target, message);
|
||||
if (current.card.step) {
|
||||
last = await askNext(deps, tx, target, actor.userId, current.card.step, input.canEnablePush);
|
||||
}
|
||||
return last;
|
||||
});
|
||||
if (!claimed) return;
|
||||
await deps.events.notify(target.threadId, claimed.event.seq);
|
||||
if (event) await deps.events.notify(target.threadId, event.seq);
|
||||
}
|
||||
|
||||
// Keep the name and title the user chose when creating the bot; the focus
|
||||
// step only suggests apps, it must not rename the bot.
|
||||
await post(deps, target, [
|
||||
{
|
||||
kind: "text",
|
||||
text: `Got it. ${capitalize(option.summary)}. I’ll see what’s already connected so I don’t make you set something up twice.`,
|
||||
},
|
||||
]);
|
||||
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 },
|
||||
});
|
||||
return intakeContext(profile, now);
|
||||
}
|
||||
|
||||
/** Posts the next onboarding question, or the closing line once every step is answered. */
|
||||
async function askNext(
|
||||
deps: OnboardingDeps,
|
||||
tx: Prisma.TransactionClient,
|
||||
target: Target,
|
||||
userId: string,
|
||||
answeredStep: OnboardingStep,
|
||||
canEnablePush = false,
|
||||
): Promise<{ seq: number }> {
|
||||
const now = new Date();
|
||||
const context = await loadIntakeContext(tx, userId, now);
|
||||
const next = nextIntakeStep(answeredStep, { canEnablePush });
|
||||
if (next) {
|
||||
await tx.userOnboarding.update({ where: { userId }, data: { step: next.id } });
|
||||
return postInTransaction(tx, target, [next.card(context)]);
|
||||
}
|
||||
// The rhythm step saved its answer outside this transaction, so the routines are readable.
|
||||
const rhythm = await getCoachRhythm(deps.prisma, userId);
|
||||
await tx.userOnboarding.update({ where: { userId }, data: { step: null, completedAt: now } });
|
||||
return postInTransaction(tx, target, [closingNote(rhythm, context)]);
|
||||
}
|
||||
|
||||
/** The apps worth connecting for a focus, as cards; they need no words around them. */
|
||||
async function focusFollowUp(
|
||||
deps: OnboardingDeps,
|
||||
actor: Actor,
|
||||
botId: string,
|
||||
option: FocusOption,
|
||||
): Promise<MessageBlock[][]> {
|
||||
const providers = deps.connectors.managedProviders();
|
||||
const catalog = (
|
||||
await Promise.all(
|
||||
@ -290,14 +333,14 @@ export async function chooseFocus(
|
||||
traceId: "onboarding.choose",
|
||||
spaceId: actor.spaceId,
|
||||
userId: actor.userId,
|
||||
botId: bot.id,
|
||||
botId,
|
||||
signal: AbortSignal.timeout(15_000),
|
||||
})
|
||||
.catch(() => []),
|
||||
),
|
||||
)
|
||||
).flat();
|
||||
const cards: MessageBlock[] = option.apps.flatMap((slug) => {
|
||||
const cards = option.apps.flatMap((slug) => {
|
||||
const entry = catalog.find(
|
||||
(item) =>
|
||||
item.slug.toLowerCase() === slug.toLowerCase() ||
|
||||
@ -316,27 +359,7 @@ export async function chooseFocus(
|
||||
},
|
||||
];
|
||||
});
|
||||
if (!cards.length) {
|
||||
await post(deps, target, [{ kind: "text", text: "What would you like to work on first?" }]);
|
||||
return;
|
||||
}
|
||||
const cardNames = cards
|
||||
.map((card) => (card.kind === "app_connect" ? card.name : ""))
|
||||
.filter(Boolean);
|
||||
const named = `${cardNames.slice(0, -1).join(", ")}${cardNames.length > 1 ? ", and " : ""}${cardNames.at(-1)}`;
|
||||
await post(deps, target, [
|
||||
{
|
||||
kind: "text",
|
||||
text: `${named} are a good place to start. Connect them here and I’ll use what you already have.`,
|
||||
},
|
||||
]);
|
||||
await post(deps, target, cards);
|
||||
await post(deps, target, [
|
||||
{
|
||||
kind: "text",
|
||||
text: `Hit those ${cards.length === 1 ? "one" : cards.length === 2 ? "two" : "three"} and I’ll start pulling the picture.`,
|
||||
},
|
||||
]);
|
||||
return cards.length > 0 ? [cards] : [];
|
||||
}
|
||||
|
||||
export async function markAppConnected(
|
||||
@ -376,7 +399,3 @@ export async function markAppConnected(
|
||||
await updateBlocks(deps, target, message.id, next);
|
||||
}
|
||||
}
|
||||
|
||||
function capitalize(value: string): string {
|
||||
return value.length > 0 ? (value[0] ?? "").toUpperCase() + value.slice(1) : value;
|
||||
}
|
||||
|
||||
@ -55,7 +55,11 @@ export function requestBodyLimit(maxSize: number): MiddlewareHandler {
|
||||
};
|
||||
}
|
||||
|
||||
/** Install limits only on framework-parsed JSON surfaces with known payload contracts. */
|
||||
/**
|
||||
* Install limits only on framework-parsed JSON surfaces with known payload contracts. Streaming
|
||||
* artifact uploads (`POST /api/artifacts`) are not buffered: the route enforces the per-kind cap
|
||||
* from Content-Length and again while writing.
|
||||
*/
|
||||
export function mountApiRequestBodyLimits(app: Hono): void {
|
||||
app.use("/api/auth/*", requestBodyLimit(MAX_AUTH_REQUEST_BYTES));
|
||||
app.use(`${LOCAL_SETTINGS_RPC}/*`, requestBodyLimit(MAX_RPC_REQUEST_BYTES));
|
||||
|
||||
@ -224,7 +224,11 @@ describe("model setup gate", () => {
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
await expect(response.json()).resolves.toEqual({
|
||||
json: expect.objectContaining({ needsModel: false }),
|
||||
json: expect.objectContaining({
|
||||
needsModel: false,
|
||||
defaultProvider: "openrouter",
|
||||
defaultModel: "test-model",
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
@ -238,7 +242,12 @@ describe("model setup gate", () => {
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
await expect(response.json()).resolves.toEqual({
|
||||
json: expect.objectContaining({ needsModel: true }),
|
||||
// The connect flow opens on the recommendation, not the keyless deployment default.
|
||||
json: expect.objectContaining({
|
||||
needsModel: true,
|
||||
defaultProvider: "anthropic",
|
||||
defaultModel: "claude-opus-5",
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
@ -627,7 +636,11 @@ describe("computer screen url", () => {
|
||||
controlRunId: null,
|
||||
};
|
||||
|
||||
const callScreenUrl = async (connectScreen: () => Promise<unknown>, updateMany = vi.fn()) => {
|
||||
const callScreenUrl = async (
|
||||
connectScreen: () => Promise<unknown>,
|
||||
updateMany = vi.fn(),
|
||||
webOrigin?: string,
|
||||
) => {
|
||||
const prisma = {
|
||||
bot: {
|
||||
findFirst: vi.fn().mockResolvedValue({
|
||||
@ -660,7 +673,7 @@ describe("computer screen url", () => {
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ json: { botId: "bot-1" } }),
|
||||
}),
|
||||
{ prefix: "/rpc", context: { actor } },
|
||||
{ prefix: "/rpc", context: { actor, webOrigin } },
|
||||
);
|
||||
return { response, updateMany };
|
||||
};
|
||||
@ -685,6 +698,16 @@ describe("computer screen url", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("points the screen at the browser's own LAN origin", async () => {
|
||||
const { response } = await callScreenUrl(
|
||||
async () => ({ url: "https://screen.example/vnc.html?token=fake-token" }),
|
||||
vi.fn(),
|
||||
"http://192.168.0.20:5173",
|
||||
);
|
||||
const { json } = await response.json();
|
||||
expect(new URL(json.url).origin).toBe("http://192.168.0.20:5173");
|
||||
});
|
||||
|
||||
it("returns desktop provider screen URLs without sealing them", async () => {
|
||||
const { response } = await callScreenUrl(async () => ({
|
||||
url: "desktop://screen/computer-1",
|
||||
@ -977,3 +1000,52 @@ describe("interrupted computer reservation release", () => {
|
||||
expect(order).toEqual(["operation", "computer"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("mcp oauth redirect", () => {
|
||||
const begin = async (redirectUri: string, webOrigin?: string) => {
|
||||
const start = vi.fn().mockResolvedValue({ status: "already_connected" });
|
||||
const deps = {
|
||||
prisma: {},
|
||||
env: { webOrigin: "http://127.0.0.1:5173" },
|
||||
mcpOAuth: { begin: start },
|
||||
} as unknown as RouterDeps;
|
||||
const { response } = await new RPCHandler(createRouter(deps)).handle(
|
||||
new Request("http://127.0.0.1/rpc/mcp/oauth/begin", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ json: { serverId: "server-1", redirectUri } }),
|
||||
}),
|
||||
{
|
||||
prefix: "/rpc",
|
||||
context: {
|
||||
actor: {
|
||||
userId: "user",
|
||||
spaceId: "space",
|
||||
email: "user@rakazo.test",
|
||||
isDeploymentOwner: true,
|
||||
},
|
||||
webOrigin,
|
||||
},
|
||||
},
|
||||
);
|
||||
return { status: response.status, start };
|
||||
};
|
||||
|
||||
it("returns to the LAN origin the browser opened", async () => {
|
||||
const { status, start } = await begin(
|
||||
"http://192.168.0.20:5173/mcp/oauth/callback",
|
||||
"http://192.168.0.20:5173",
|
||||
);
|
||||
expect(status).toBe(200);
|
||||
expect(start).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects a redirect to any other origin", async () => {
|
||||
const { status, start } = await begin(
|
||||
"https://attacker.example/mcp/oauth/callback",
|
||||
"http://192.168.0.20:5173",
|
||||
);
|
||||
expect(status).toBe(400);
|
||||
expect(start).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -20,6 +20,16 @@ describe("run activity copy", () => {
|
||||
).toBe("Maya asked: Please check the release workflow.");
|
||||
});
|
||||
|
||||
it("names an autopilot step by the user's order", () => {
|
||||
expect(
|
||||
activityPromptSnippet({
|
||||
trigger: "autopilot",
|
||||
prompt:
|
||||
"Autopilot: continue the user's order on your own.\nOrder: Tidy the tickets\nUntil: done",
|
||||
}),
|
||||
).toBe("Tidy the tickets");
|
||||
});
|
||||
|
||||
it("names a delegated task by its title instead of its wake prompt", () => {
|
||||
expect(
|
||||
activityPromptSnippet({
|
||||
|
||||
@ -26,6 +26,10 @@ export function activityPromptSnippet(
|
||||
const task = parsed.success ? taskBlockOf(parsed.data) : undefined;
|
||||
return promptSnippet(task?.title ?? input.prompt, max);
|
||||
}
|
||||
if (input.trigger === "autopilot") {
|
||||
// The continuation prompt is for the model; people know the run by its order.
|
||||
return promptSnippet(/^Order: (.*)$/m.exec(input.prompt)?.[1] ?? input.prompt, max);
|
||||
}
|
||||
if (input.trigger !== "bot_message") return promptSnippet(input.prompt, max);
|
||||
const parsed = MessageBlock.array().safeParse(input.sourceBlocks);
|
||||
const message = parsed.success ? botMessageContext(parsed.data) : undefined;
|
||||
|
||||
275
apps/api/src/thread-cards.postgres.test.ts
Normal file
275
apps/api/src/thread-cards.postgres.test.ts
Normal file
@ -0,0 +1,275 @@
|
||||
import type { JobPublisher } from "@rakazo/adapter-kit";
|
||||
import type { Actor, CardBlock, MessageBlock } from "@rakazo/contracts";
|
||||
import { buildCardFromTool, buildSuggestionCard } from "@rakazo/core";
|
||||
import { approvalEffectKey } from "@rakazo/core/node/approval-effect-key";
|
||||
import type { PrismaClient, ThreadEvents } from "@rakazo/db";
|
||||
import { createDb, provisionMessagingIdentity } from "@rakazo/db";
|
||||
import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
|
||||
import { actOnThreadCard } from "./thread-cards.js";
|
||||
import type { ThreadTarget } from "./thread-target.js";
|
||||
import { resolveThreadTarget } from "./thread-target.js";
|
||||
|
||||
const databaseUrl = process.env.DATABASE_URL;
|
||||
const describePostgres =
|
||||
process.env.VERIFY_DATABASE && databaseUrl ? describe.sequential : describe.skip;
|
||||
|
||||
describePostgres("actOnThreadCard (PostgreSQL)", () => {
|
||||
let prisma: PrismaClient;
|
||||
let close: () => Promise<void>;
|
||||
let identity: { userId: string; spaceId: string; botId: string; threadId: string };
|
||||
let actor: Actor;
|
||||
let target: ThreadTarget;
|
||||
const notify = vi.fn(async () => undefined);
|
||||
const enqueue = vi.fn(async () => undefined);
|
||||
let deps: { prisma: PrismaClient; events: ThreadEvents; jobs: JobPublisher };
|
||||
let seq = 0;
|
||||
let nonce = 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: `+1556${Date.now().toString().slice(-7)}` },
|
||||
{ signupsEnabled: undefined, signupAllowlist: undefined },
|
||||
);
|
||||
actor = {
|
||||
userId: identity.userId,
|
||||
spaceId: identity.spaceId,
|
||||
email: "card@example.com",
|
||||
isDeploymentOwner: false,
|
||||
};
|
||||
deps = {
|
||||
prisma,
|
||||
events: { notify } as unknown as ThreadEvents,
|
||||
jobs: {
|
||||
enqueue,
|
||||
cancel: vi.fn(async () => undefined),
|
||||
close: vi.fn(async () => undefined),
|
||||
} as unknown as JobPublisher,
|
||||
};
|
||||
target = await resolveThreadTarget(prisma, actor, { botId: identity.botId });
|
||||
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 postCard(args: Parameters<typeof buildCardFromTool>[0]) {
|
||||
const built = buildCardFromTool(args, `card${++seq}`);
|
||||
if (!("ok" in built)) throw new Error(built.error);
|
||||
const message = await prisma.message.create({
|
||||
data: {
|
||||
threadId: identity.threadId,
|
||||
seq,
|
||||
role: "bot",
|
||||
botId: identity.botId,
|
||||
blocks: [built.block],
|
||||
},
|
||||
});
|
||||
return { messageId: message.id, cardId: built.block.id! };
|
||||
}
|
||||
|
||||
async function card(messageId: string) {
|
||||
const row = await prisma.message.findUniqueOrThrow({ where: { id: messageId } });
|
||||
return (row.blocks as MessageBlock[])[0] as CardBlock;
|
||||
}
|
||||
|
||||
const act = (input: Omit<Parameters<typeof actOnThreadCard>[3], "clientNonce">, again?: string) =>
|
||||
actOnThreadCard(deps, actor, target, { ...input, clientNonce: again ?? `nonce-${++nonce}` });
|
||||
|
||||
it("ticks a checklist item without starting a run", async () => {
|
||||
const posted = await postCard({ title: "Shopping", checklist: ["Milk", "Eggs"] });
|
||||
await act({
|
||||
...posted,
|
||||
act: { type: "toggle", sectionId: "checklist", itemId: "i2", checked: true },
|
||||
});
|
||||
const updated = await card(posted.messageId);
|
||||
expect(updated.sections?.[1]).toMatchObject({
|
||||
items: [{ id: "i1" }, { id: "i2", checked: true }],
|
||||
});
|
||||
expect(await prisma.message.count({ where: { replyToMessageId: posted.messageId } })).toBe(0);
|
||||
expect(
|
||||
await prisma.event.count({
|
||||
where: {
|
||||
threadId: identity.threadId,
|
||||
type: "thread.message.updated",
|
||||
payload: { path: ["messageId"], equals: posted.messageId },
|
||||
},
|
||||
}),
|
||||
).toBe(1);
|
||||
});
|
||||
|
||||
it("answers a choice once and sends it to the bot as the user's reply", async () => {
|
||||
const posted = await postCard({ title: "Dinner", options: ["Pasta", "Curry"] });
|
||||
const pick = {
|
||||
...posted,
|
||||
act: { type: "select" as const, sectionId: "select", optionId: "o2" },
|
||||
};
|
||||
await act(pick, "pick-curry");
|
||||
// A retried tap is a no-op rather than an error.
|
||||
await act(pick, "pick-curry");
|
||||
expect((await card(posted.messageId)).answer).toEqual({ text: "Curry" });
|
||||
const replies = await prisma.message.findMany({
|
||||
where: { replyToMessageId: posted.messageId },
|
||||
select: { role: true, blocks: true, runId: true },
|
||||
});
|
||||
expect(replies).toHaveLength(1);
|
||||
expect(replies[0]).toMatchObject({ role: "user", blocks: [{ kind: "text", text: "Curry" }] });
|
||||
expect(replies[0]?.runId).toBeTruthy();
|
||||
await expect(
|
||||
act({ ...posted, act: { type: "select", sectionId: "select", optionId: "o1" } }),
|
||||
).rejects.toThrow("already answered");
|
||||
});
|
||||
|
||||
async function postSuggestion(createdAt?: Date) {
|
||||
const task = await prisma.task.create({
|
||||
data: {
|
||||
spaceId: identity.spaceId,
|
||||
botId: identity.botId,
|
||||
threadId: identity.threadId,
|
||||
userId: identity.userId,
|
||||
prompt: "I should move more",
|
||||
status: "completed",
|
||||
},
|
||||
});
|
||||
const run = await prisma.run.create({
|
||||
data: {
|
||||
spaceId: identity.spaceId,
|
||||
botId: identity.botId,
|
||||
threadId: identity.threadId,
|
||||
taskId: task.id,
|
||||
userId: identity.userId,
|
||||
status: "completed",
|
||||
trigger: "user",
|
||||
},
|
||||
});
|
||||
const call = {
|
||||
tool: "schedule_create",
|
||||
args: { name: "Morning walk", prompt: "Take a walk.", cron: "0 8 * * *" },
|
||||
};
|
||||
const effect = await prisma.externalEffect.create({
|
||||
data: {
|
||||
spaceId: identity.spaceId,
|
||||
runId: run.id,
|
||||
kind: "suggestion",
|
||||
idempotencyKey: `suggestion:${run.id}:test`,
|
||||
status: "proposed",
|
||||
request: { ...call, botId: identity.botId, threadId: identity.threadId, subject: "x" },
|
||||
...(createdAt ? { createdAt } : {}),
|
||||
},
|
||||
});
|
||||
const block = buildSuggestionCard({ id: effect.id, effectId: effect.id, ...call });
|
||||
const message = await prisma.message.create({
|
||||
data: {
|
||||
threadId: identity.threadId,
|
||||
seq: ++seq,
|
||||
role: "bot",
|
||||
botId: identity.botId,
|
||||
blocks: [block],
|
||||
},
|
||||
});
|
||||
await prisma.externalEffect.update({
|
||||
where: { id: effect.id },
|
||||
data: {
|
||||
request: {
|
||||
...call,
|
||||
botId: identity.botId,
|
||||
threadId: identity.threadId,
|
||||
subject: "x",
|
||||
card: { threadId: identity.threadId, messageId: message.id },
|
||||
},
|
||||
},
|
||||
});
|
||||
return { messageId: message.id, cardId: effect.id, effectId: effect.id, ...call };
|
||||
}
|
||||
|
||||
it("runs an accepted suggestion as an approved run, once", async () => {
|
||||
const posted = await postSuggestion();
|
||||
enqueue.mockClear();
|
||||
const accept = {
|
||||
messageId: posted.messageId,
|
||||
cardId: posted.cardId,
|
||||
act: { type: "action" as const, actionId: "accept" },
|
||||
};
|
||||
await act(accept, "accept-walk");
|
||||
await act(accept, "accept-walk");
|
||||
expect((await card(posted.messageId)).suggestion?.status).toBe("running");
|
||||
const effect = await prisma.externalEffect.findUniqueOrThrow({
|
||||
where: { id: posted.effectId },
|
||||
});
|
||||
expect(effect.status).toBe("accepted");
|
||||
const runId = (effect.result as { runId: string }).runId;
|
||||
const run = await prisma.run.findUniqueOrThrow({ where: { id: runId } });
|
||||
expect(run).toMatchObject({ trigger: "suggestion", botId: identity.botId, status: "queued" });
|
||||
const approved = await prisma.externalEffect.findMany({ where: { runId } });
|
||||
expect(approved).toEqual([
|
||||
expect.objectContaining({
|
||||
kind: "schedule_create",
|
||||
status: "approved",
|
||||
idempotencyKey: approvalEffectKey(runId, "schedule_create", posted.args),
|
||||
}),
|
||||
]);
|
||||
expect(enqueue).toHaveBeenCalledTimes(1);
|
||||
// No reply goes to the bot; the card itself carries the outcome.
|
||||
expect(await prisma.message.count({ where: { replyToMessageId: posted.messageId } })).toBe(0);
|
||||
await expect(act({ ...accept, act: { type: "action", actionId: "dismiss" } })).rejects.toThrow(
|
||||
"already settled",
|
||||
);
|
||||
});
|
||||
|
||||
it("dismisses a suggestion without running anything", async () => {
|
||||
const posted = await postSuggestion();
|
||||
await act({
|
||||
messageId: posted.messageId,
|
||||
cardId: posted.cardId,
|
||||
act: { type: "action", actionId: "dismiss" },
|
||||
});
|
||||
expect((await card(posted.messageId)).suggestion?.status).toBe("dismissed");
|
||||
const effect = await prisma.externalEffect.findUniqueOrThrow({
|
||||
where: { id: posted.effectId },
|
||||
});
|
||||
expect(effect).toMatchObject({ status: "dismissed" });
|
||||
expect((effect.result as { runId: unknown }).runId).toBeNull();
|
||||
});
|
||||
|
||||
it("lets an old suggestion expire instead of running it", async () => {
|
||||
const posted = await postSuggestion(new Date(Date.now() - 8 * 24 * 60 * 60 * 1000));
|
||||
await act({
|
||||
messageId: posted.messageId,
|
||||
cardId: posted.cardId,
|
||||
act: { type: "action", actionId: "accept" },
|
||||
});
|
||||
expect((await card(posted.messageId)).suggestion?.status).toBe("expired");
|
||||
expect(
|
||||
await prisma.externalEffect.findUniqueOrThrow({ where: { id: posted.effectId } }),
|
||||
).toMatchObject({ status: "expired", result: null });
|
||||
});
|
||||
|
||||
it("keeps other threads' cards out of reach", async () => {
|
||||
const posted = await postCard({ title: "Elsewhere", checklist: ["One"] });
|
||||
await expect(
|
||||
act({
|
||||
...posted,
|
||||
cardId: "not-this-card",
|
||||
act: { type: "toggle", sectionId: "checklist", itemId: "i1", checked: true },
|
||||
}),
|
||||
).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
242
apps/api/src/thread-cards.ts
Normal file
242
apps/api/src/thread-cards.ts
Normal file
@ -0,0 +1,242 @@
|
||||
import { ORPCError } from "@orpc/server";
|
||||
import type { JobPublisher } from "@rakazo/adapter-kit";
|
||||
import { runContinueJob } from "@rakazo/adapter-kit";
|
||||
import type { Actor, CardAct, CardBlock, MessageBlock } from "@rakazo/contracts";
|
||||
import { SUGGESTION_TTL_MS } from "@rakazo/contracts";
|
||||
import type { CardActResult } from "@rakazo/core";
|
||||
import { applyCardAct, withSuggestionStatus } from "@rakazo/core";
|
||||
import { approvalEffectKey } from "@rakazo/core/node/approval-effect-key";
|
||||
import type { Prisma, PrismaClient, ThreadEvents } from "@rakazo/db";
|
||||
import { IsolationError, updateCardInTransaction } from "@rakazo/db";
|
||||
import { getLogger } from "@rakazo/logging";
|
||||
import type { ThreadTarget } from "./thread-target.js";
|
||||
import { sendThreadMessage } from "./thread-target.js";
|
||||
|
||||
type CardDeps = { prisma: PrismaClient; events: ThreadEvents; jobs: JobPublisher };
|
||||
|
||||
/** What an accepted suggestion runs: the stored call, by the bot it was meant for. */
|
||||
type StoredSuggestion = {
|
||||
tool: string;
|
||||
args: Record<string, unknown>;
|
||||
botId: string;
|
||||
threadId: string;
|
||||
card?: { threadId: string; messageId: string };
|
||||
};
|
||||
|
||||
function storedSuggestion(value: unknown): StoredSuggestion | null {
|
||||
if (!value || typeof value !== "object") return null;
|
||||
const request = value as Partial<StoredSuggestion>;
|
||||
return typeof request.tool === "string" &&
|
||||
typeof request.botId === "string" &&
|
||||
typeof request.threadId === "string" &&
|
||||
request.args &&
|
||||
typeof request.args === "object"
|
||||
? (request as StoredSuggestion)
|
||||
: null;
|
||||
}
|
||||
|
||||
function resultObject(value: unknown): Record<string, unknown> {
|
||||
return value && typeof value === "object" && !Array.isArray(value)
|
||||
? (value as Record<string, unknown>)
|
||||
: {};
|
||||
}
|
||||
|
||||
async function loadCard(prisma: PrismaClient, threadId: string, messageId: string, cardId: string) {
|
||||
const message = await prisma.message.findFirst({
|
||||
where: { id: messageId, threadId, role: "bot" },
|
||||
select: { blocks: true },
|
||||
});
|
||||
const blocks = (Array.isArray(message?.blocks) ? message.blocks : []) as MessageBlock[];
|
||||
const card = blocks.find((block) => block.kind === "card" && block.id === cardId);
|
||||
return card?.kind === "card" ? card : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply one tap on a bot's card. The card changes in place for everyone watching the thread; a
|
||||
* choice or an action then goes to the card's bot as the user's reply, through the normal send.
|
||||
* Accept or Dismiss on a suggestion runs or drops the stored call instead.
|
||||
*/
|
||||
export async function actOnThreadCard(
|
||||
deps: CardDeps,
|
||||
actor: Actor,
|
||||
target: ThreadTarget,
|
||||
input: { messageId: string; cardId: string; act: CardAct; clientNonce: string },
|
||||
options: { requireModel?: () => Promise<void> } = {},
|
||||
): Promise<{ ok: true }> {
|
||||
// A retried answer already landed as the user's reply; the card was answered with it.
|
||||
const replayed = await deps.prisma.message.findUnique({
|
||||
where: { threadId_clientNonce: { threadId: target.threadId, clientNonce: input.clientNonce } },
|
||||
select: { id: true },
|
||||
});
|
||||
if (replayed) return { ok: true };
|
||||
|
||||
const card = await loadCard(deps.prisma, target.threadId, input.messageId, input.cardId);
|
||||
if (!card) throw new IsolationError();
|
||||
if (card.suggestion && input.act.type === "action") {
|
||||
return settleSuggestion(deps, actor, target, input, card.suggestion.effectId);
|
||||
}
|
||||
if (input.act.type !== "toggle") await options.requireModel?.();
|
||||
|
||||
let outcome: CardActResult | undefined;
|
||||
let botId: string | null = null;
|
||||
const updated = await deps.prisma.$transaction(async (tx) => {
|
||||
const message = await tx.message.findFirst({
|
||||
where: { id: input.messageId, threadId: target.threadId },
|
||||
select: { botId: true },
|
||||
});
|
||||
botId = message?.botId ?? null;
|
||||
return updateCardInTransaction(
|
||||
tx,
|
||||
{ threadId: target.threadId, messageId: input.messageId, cardId: input.cardId },
|
||||
(current) => {
|
||||
outcome = applyCardAct(current, input.act);
|
||||
return outcome.ok && outcome.changed ? outcome.block : null;
|
||||
},
|
||||
);
|
||||
});
|
||||
if (!outcome) throw new IsolationError();
|
||||
if (!outcome.ok) throw new ORPCError("CONFLICT", { message: outcome.error });
|
||||
if (updated) await notify(deps.events, target.threadId, updated.seq);
|
||||
if (outcome.reply) {
|
||||
await sendThreadMessage(deps, actor, target, {
|
||||
text: outcome.reply,
|
||||
replyToMessageId: input.messageId,
|
||||
clientNonce: input.clientNonce,
|
||||
// In a group the answer goes to the bot that asked, not to whoever leads.
|
||||
...(target.kind === "group" && botId
|
||||
? { mentions: [{ kind: "bot" as const, id: botId }] }
|
||||
: {}),
|
||||
});
|
||||
}
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
async function notify(events: ThreadEvents, threadId: string, seq: number) {
|
||||
await events.notify(threadId, seq).catch((error) => {
|
||||
getLogger().error("thread card realtime notification", error);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Accept runs the stored call as its own run of the bot it was meant for, already approved:
|
||||
* accepting is the approval. Dismiss only closes the card. Neither needs a model.
|
||||
*/
|
||||
async function settleSuggestion(
|
||||
deps: CardDeps,
|
||||
actor: Actor,
|
||||
target: ThreadTarget,
|
||||
input: { messageId: string; cardId: string; act: CardAct; clientNonce: string },
|
||||
effectId: string,
|
||||
): Promise<{ ok: true }> {
|
||||
const settled = await deps.prisma.$transaction(async (tx) => {
|
||||
let outcome: CardActResult | undefined;
|
||||
const effect = await tx.externalEffect.findUnique({ where: { id: effectId } });
|
||||
const request = storedSuggestion(effect?.request);
|
||||
if (
|
||||
!effect ||
|
||||
!request ||
|
||||
effect.kind !== "suggestion" ||
|
||||
effect.spaceId !== actor.spaceId ||
|
||||
request.card?.messageId !== input.messageId
|
||||
) {
|
||||
throw new IsolationError();
|
||||
}
|
||||
// A retried tap that already went through.
|
||||
if (resultObject(effect.result).clientNonce === input.clientNonce) return null;
|
||||
const expired = Date.now() - effect.createdAt.getTime() > SUGGESTION_TTL_MS;
|
||||
const updated = await updateCardInTransaction(
|
||||
tx,
|
||||
{ threadId: target.threadId, messageId: input.messageId, cardId: input.cardId },
|
||||
(current: CardBlock) => {
|
||||
if (expired && current.suggestion?.status === "proposed") {
|
||||
return withSuggestionStatus(current, "expired");
|
||||
}
|
||||
outcome = applyCardAct(current, input.act);
|
||||
return outcome.ok && outcome.changed ? outcome.block : null;
|
||||
},
|
||||
);
|
||||
if (expired) {
|
||||
await tx.externalEffect.updateMany({
|
||||
where: { id: effect.id, status: "proposed" },
|
||||
data: { status: "expired" },
|
||||
});
|
||||
return { seq: updated?.seq ?? null, runId: null };
|
||||
}
|
||||
if (!outcome) throw new IsolationError();
|
||||
if (!outcome.ok) throw new ORPCError("CONFLICT", { message: outcome.error });
|
||||
if (effect.status !== "proposed") {
|
||||
throw new ORPCError("CONFLICT", { message: "This suggestion was already settled." });
|
||||
}
|
||||
const runId =
|
||||
outcome.suggestion === "accept"
|
||||
? await startAcceptedRun(tx, actor, effect.spaceId, request)
|
||||
: null;
|
||||
await tx.externalEffect.update({
|
||||
where: { id: effect.id },
|
||||
data: {
|
||||
status: runId ? "accepted" : "dismissed",
|
||||
result: { ...resultObject(effect.result), clientNonce: input.clientNonce, runId },
|
||||
},
|
||||
});
|
||||
return { seq: updated?.seq ?? null, runId };
|
||||
});
|
||||
if (settled?.seq) await notify(deps.events, target.threadId, settled.seq);
|
||||
if (settled?.runId) {
|
||||
await deps.jobs.enqueue(runContinueJob(settled.runId)).catch((error) => {
|
||||
getLogger().error("accepted suggestion enqueue", error);
|
||||
});
|
||||
}
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
async function startAcceptedRun(
|
||||
tx: Prisma.TransactionClient,
|
||||
actor: Actor,
|
||||
spaceId: string,
|
||||
request: StoredSuggestion,
|
||||
): Promise<string> {
|
||||
const thread = await tx.thread.findFirst({
|
||||
where: { id: request.threadId, spaceId },
|
||||
select: { id: true },
|
||||
});
|
||||
const bot = await tx.bot.findFirst({
|
||||
where: { id: request.botId, spaceId },
|
||||
select: { id: true },
|
||||
});
|
||||
if (!thread || !bot) {
|
||||
throw new ORPCError("CONFLICT", { message: "The bot for this suggestion is gone." });
|
||||
}
|
||||
const task = await tx.task.create({
|
||||
data: {
|
||||
spaceId,
|
||||
botId: bot.id,
|
||||
threadId: thread.id,
|
||||
userId: actor.userId,
|
||||
prompt: `Accepted suggestion: ${request.tool}`,
|
||||
status: "queued",
|
||||
},
|
||||
});
|
||||
const run = await tx.run.create({
|
||||
data: {
|
||||
spaceId,
|
||||
botId: bot.id,
|
||||
threadId: thread.id,
|
||||
taskId: task.id,
|
||||
userId: actor.userId,
|
||||
status: "queued",
|
||||
trigger: "suggestion",
|
||||
},
|
||||
});
|
||||
// The run replays this exact call; the approval gate sees it as already approved.
|
||||
await tx.externalEffect.create({
|
||||
data: {
|
||||
spaceId,
|
||||
runId: run.id,
|
||||
kind: request.tool,
|
||||
idempotencyKey: approvalEffectKey(run.id, request.tool, request.args),
|
||||
status: "approved",
|
||||
request: request.args as never,
|
||||
},
|
||||
});
|
||||
return run.id;
|
||||
}
|
||||
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);
|
||||
});
|
||||
|
||||
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 () => {
|
||||
const findMany = vi.fn(async () => [
|
||||
{
|
||||
@ -281,45 +189,6 @@ describe("thread message pages", () => {
|
||||
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 () => {
|
||||
const receiptRows = [
|
||||
{
|
||||
@ -383,21 +252,13 @@ describe("thread message pages", () => {
|
||||
createdAt: new Date("2026-08-16T00:00:01.000Z"),
|
||||
},
|
||||
];
|
||||
const findMany = vi.fn().mockResolvedValueOnce(receiptRows).mockResolvedValueOnce(olderRows);
|
||||
const prisma = {
|
||||
message: { findMany },
|
||||
run: {
|
||||
findMany: vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce([{ id: "run-peer" }])
|
||||
.mockResolvedValueOnce([]),
|
||||
},
|
||||
} as unknown as PrismaClient;
|
||||
const queryRaw = vi.fn().mockResolvedValueOnce(receiptRows).mockResolvedValueOnce(olderRows);
|
||||
const prisma = { $queryRaw: queryRaw } as unknown as PrismaClient;
|
||||
|
||||
const page = await loadMessagePage(prisma, "thread-1", undefined, 2);
|
||||
|
||||
expect(page.messages.map((message) => message.id)).toEqual(["message-user"]);
|
||||
expect(findMany).toHaveBeenCalledTimes(2);
|
||||
expect(queryRaw).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("returns a receipt-only page when the client displays peer receipts", async () => {
|
||||
@ -419,11 +280,8 @@ describe("thread message pages", () => {
|
||||
runId: "run-peer",
|
||||
createdAt: new Date(`2026-08-16T00:00:0${seq}.000Z`),
|
||||
});
|
||||
const findMany = vi.fn(async () => [receipt(3), receipt(2), receipt(1)]);
|
||||
const prisma = {
|
||||
message: { findMany },
|
||||
run: { findMany: vi.fn(async () => [{ id: "run-peer" }]) },
|
||||
} as unknown as PrismaClient;
|
||||
const queryRaw = vi.fn(async () => [receipt(3), receipt(2), receipt(1)]);
|
||||
const prisma = { $queryRaw: queryRaw } as unknown as PrismaClient;
|
||||
|
||||
const page = await loadMessagePage(prisma, "thread-1", undefined, 2, undefined, false, true);
|
||||
|
||||
@ -432,10 +290,10 @@ describe("thread message pages", () => {
|
||||
"message-receipt-3",
|
||||
]);
|
||||
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 () =>
|
||||
[5, 4, 3].map((seq) => ({
|
||||
id: `message-${seq}`,
|
||||
@ -449,7 +307,7 @@ describe("thread message pages", () => {
|
||||
);
|
||||
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({
|
||||
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 () => {
|
||||
const findMany = vi.fn(async () => [
|
||||
const queryRaw = vi.fn(async () => [
|
||||
{
|
||||
id: "message-0",
|
||||
threadId: "thread-1",
|
||||
@ -472,7 +330,7 @@ describe("thread message pages", () => {
|
||||
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);
|
||||
|
||||
|
||||
@ -1,9 +1,18 @@
|
||||
import type { MessageBlock, ThreadMessage, ThreadMessagePage } from "@rakazo/contracts";
|
||||
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;
|
||||
|
||||
/** 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(
|
||||
prisma: MessageDb,
|
||||
threadId: string,
|
||||
@ -47,37 +56,69 @@ export async function loadMessagePage(
|
||||
}
|
||||
}
|
||||
|
||||
let cursor = before;
|
||||
while (true) {
|
||||
if (includePeerRuns) {
|
||||
const rows = await prisma.message.findMany({
|
||||
where: {
|
||||
threadId,
|
||||
...(cursor === undefined ? {} : { seq: { lt: cursor } }),
|
||||
...(before === undefined ? {} : { seq: { lt: before } }),
|
||||
},
|
||||
orderBy: { seq: "desc" },
|
||||
take: pageSize + 1,
|
||||
});
|
||||
const hasOlder = rows.length > pageSize;
|
||||
const pageRows = rows.slice(0, pageSize).reverse();
|
||||
const visibleRows = includePeerRuns ? pageRows : await withoutPeerRunMessages(prisma, pageRows);
|
||||
return toPage(threadId, rows, pageSize);
|
||||
}
|
||||
|
||||
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.
|
||||
// Mobile explicitly retains them and must receive each page for pagination.
|
||||
const hasSubstantive = visibleRows.some(
|
||||
(row) => !isPeerReceiptBlocks(row.blocks as MessageBlock[]),
|
||||
);
|
||||
if (hasSubstantive || includePeerReceipts || !hasOlder || includePeerRuns) {
|
||||
return {
|
||||
threadId,
|
||||
messages: visibleRows.map(toThreadMessage),
|
||||
olderCursor: hasOlder ? (pageRows[0]?.seq ?? null) : null,
|
||||
};
|
||||
}
|
||||
// TODO: only rescan when a raw page is entirely peer output. Consider a run relation if
|
||||
// long peer-only histories make this path hot.
|
||||
cursor = pageRows[0]?.seq;
|
||||
const hasSubstantive = page.messages.some((message) => !isPeerReceiptBlocks(message.blocks));
|
||||
if (hasSubstantive || includePeerReceipts || page.olderCursor === null) return page;
|
||||
cursor = page.olderCursor;
|
||||
}
|
||||
}
|
||||
|
||||
type MessageRow = Parameters<typeof toThreadMessage>[0];
|
||||
|
||||
function toPage(threadId: string, rows: MessageRow[], pageSize: number): ThreadMessagePage {
|
||||
const pageRows = rows.slice(0, pageSize).reverse();
|
||||
return {
|
||||
threadId,
|
||||
messages: pageRows.map(toThreadMessage),
|
||||
olderCursor: rows.length > pageSize ? (pageRows[0]?.seq ?? null) : null,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Newest-first transcript rows with peer-run activity excluded in the database, so a long
|
||||
* peer-only stretch costs one query instead of a rescan per hidden page.
|
||||
*/
|
||||
function visibleMessageRows(
|
||||
prisma: MessageDb,
|
||||
threadId: string,
|
||||
before: number | undefined,
|
||||
limit: number,
|
||||
): Promise<MessageRow[]> {
|
||||
return prisma.$queryRaw<MessageRow[]>`
|
||||
SELECT m.id, m."threadId", m.seq, m.role, m.blocks, m."botId", m."replyToMessageId",
|
||||
m."replyQuote", m."runId", m."createdAt"
|
||||
FROM messages m
|
||||
LEFT JOIN runs r ON r.id = m."runId"
|
||||
WHERE m."threadId" = ${threadId}
|
||||
${before === undefined ? Prisma.empty : Prisma.sql`AND m.seq < ${before}`}
|
||||
AND (r.trigger IS DISTINCT FROM 'bot_message' OR ${Prisma.join(
|
||||
PEER_VISIBLE_BLOCK_KINDS.map(
|
||||
(kind) => Prisma.sql`m.blocks @> ${JSON.stringify([{ kind }])}::jsonb`,
|
||||
),
|
||||
" OR ",
|
||||
)})
|
||||
ORDER BY m.seq DESC
|
||||
LIMIT ${limit}
|
||||
`;
|
||||
}
|
||||
|
||||
export async function loadAllMessages(
|
||||
prisma: PrismaClient,
|
||||
threadId: string,
|
||||
@ -106,15 +147,8 @@ async function withoutPeerRunMessages<T extends { runId: string | null; blocks:
|
||||
const peerRunIds = new Set(peerRuns.map((run) => run.id));
|
||||
return rows.filter((row) => {
|
||||
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[];
|
||||
return blocks.some(
|
||||
(block) =>
|
||||
block.kind === "bot_message_sent" ||
|
||||
block.kind === "bot_message_received" ||
|
||||
block.kind === "ask" ||
|
||||
block.kind === "text",
|
||||
);
|
||||
return blocks.some((block) => PEER_VISIBLE_BLOCK_KINDS.includes(block.kind));
|
||||
});
|
||||
}
|
||||
|
||||
@ -159,10 +193,8 @@ export function shouldForwardPeerThreadEvent(event: {
|
||||
!!block &&
|
||||
typeof block === "object" &&
|
||||
"kind" in block &&
|
||||
(block.kind === "bot_message_received" ||
|
||||
block.kind === "bot_message_sent" ||
|
||||
block.kind === "ask" ||
|
||||
block.kind === "text"),
|
||||
typeof block.kind === "string" &&
|
||||
PEER_VISIBLE_BLOCK_KINDS.includes(block.kind),
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@ -183,7 +183,7 @@ describe("threadSnapshot", () => {
|
||||
},
|
||||
]);
|
||||
const tx = {
|
||||
$queryRaw: vi.fn().mockResolvedValue([{ id: "thread-1" }]),
|
||||
$queryRaw: threadQueries(),
|
||||
message: { findMany: vi.fn().mockResolvedValue([]) },
|
||||
event: {
|
||||
findFirst: vi.fn().mockResolvedValue({ seq: 4 }),
|
||||
@ -203,7 +203,10 @@ describe("threadSnapshot", () => {
|
||||
|
||||
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.objectContaining({
|
||||
where: expect.objectContaining({
|
||||
@ -245,7 +248,7 @@ describe("threadSnapshot", () => {
|
||||
const findManyEvents = vi.fn();
|
||||
const findFirstRun = botRunFindFirst([run]);
|
||||
const tx = {
|
||||
$queryRaw: vi.fn().mockResolvedValue([{ id: "thread-1" }]),
|
||||
$queryRaw: threadQueries(),
|
||||
message: { findMany: vi.fn().mockResolvedValue([]) },
|
||||
event: {
|
||||
findFirst: vi.fn().mockResolvedValue(null),
|
||||
@ -330,7 +333,7 @@ describe("threadSnapshot", () => {
|
||||
prisma: {
|
||||
$transaction: vi.fn(async (callback: (client: unknown) => unknown) =>
|
||||
callback({
|
||||
$queryRaw: vi.fn().mockResolvedValue([{ id: "thread-1" }]),
|
||||
$queryRaw: threadQueries(),
|
||||
message: { findMany: vi.fn().mockResolvedValue([]) },
|
||||
event: {
|
||||
findFirst: vi.fn().mockResolvedValue(null),
|
||||
@ -385,7 +388,7 @@ describe("threadSnapshot", () => {
|
||||
};
|
||||
const findFirstRun = botRunFindFirst([failed, completed]);
|
||||
const tx = {
|
||||
$queryRaw: vi.fn().mockResolvedValue([{ id: "thread-1" }]),
|
||||
$queryRaw: threadQueries(),
|
||||
message: { findMany: vi.fn().mockResolvedValue([]) },
|
||||
event: {
|
||||
findFirst: vi.fn().mockResolvedValue(null),
|
||||
@ -421,7 +424,7 @@ describe("threadSnapshot", () => {
|
||||
const findManyEvents = vi.fn();
|
||||
const findFirstRun = botRunFindFirst([]);
|
||||
const tx = {
|
||||
$queryRaw: vi.fn().mockResolvedValue([{ id: "thread-1" }]),
|
||||
$queryRaw: threadQueries(),
|
||||
message: { findMany: vi.fn().mockResolvedValue([]) },
|
||||
event: {
|
||||
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>) {
|
||||
const tx = {
|
||||
$queryRaw: vi.fn().mockResolvedValue([{ id: "thread-1" }]),
|
||||
$queryRaw: threadQueries(),
|
||||
message: { findMany: vi.fn().mockResolvedValue([]) },
|
||||
event: {
|
||||
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 type { CoachRhythmDeps } from "@rakazo/adapters";
|
||||
import { syncCoachTimeZone } from "@rakazo/adapters";
|
||||
import type {
|
||||
Actor,
|
||||
ForgetUserFacts,
|
||||
@ -29,32 +31,32 @@ export function getProfile(prisma: PrismaClient, actor: Actor): Promise<UserProf
|
||||
}
|
||||
|
||||
export async function updateProfile(
|
||||
prisma: PrismaClient,
|
||||
deps: CoachRhythmDeps,
|
||||
actor: Actor,
|
||||
input: UpdateUserProfile,
|
||||
): Promise<UserProfileState> {
|
||||
try {
|
||||
await updateUserProfile(prisma, actor.userId, input);
|
||||
await updateUserProfile(deps.prisma, actor.userId, input);
|
||||
} catch (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. */
|
||||
export async function forgetProfile(
|
||||
prisma: PrismaClient,
|
||||
deps: CoachRhythmDeps,
|
||||
actor: Actor,
|
||||
input: ForgetUserFacts,
|
||||
): Promise<UserProfileState> {
|
||||
await forgetUserFacts(
|
||||
prisma,
|
||||
actor.userId,
|
||||
input.factIds
|
||||
? { factIds: input.factIds }
|
||||
: input.category
|
||||
? { category: input.category }
|
||||
: { all: true },
|
||||
);
|
||||
return getUserProfileState(prisma, actor.userId);
|
||||
const target = input.factIds
|
||||
? { factIds: input.factIds }
|
||||
: input.category
|
||||
? { category: input.category }
|
||||
: { all: true as const };
|
||||
await forgetUserFacts(deps.prisma, actor.userId, target);
|
||||
// Forgetting everything clears the time zone too.
|
||||
if ("all" in target) await syncCoachTimeZone(deps, actor.userId);
|
||||
return getUserProfileState(deps.prisma, actor.userId);
|
||||
}
|
||||
|
||||
91
apps/api/src/voice-transcribe.test.ts
Normal file
91
apps/api/src/voice-transcribe.test.ts
Normal file
@ -0,0 +1,91 @@
|
||||
import type { Server } from "node:http";
|
||||
import { createServer } from "node:http";
|
||||
import type { AddressInfo } from "node:net";
|
||||
import { NoVoiceConfigured } from "@rakazo/adapters";
|
||||
import type { Actor } from "@rakazo/contracts";
|
||||
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||
import type { VoiceDeps } from "./voice.js";
|
||||
import { toVoiceStatus, transcribeVoice } from "./voice.js";
|
||||
|
||||
const actor: Actor = {
|
||||
userId: "user-1",
|
||||
spaceId: "space-1",
|
||||
email: "user@rakazo.test",
|
||||
isDeploymentOwner: false,
|
||||
};
|
||||
|
||||
/** A user without a voice credential, on a deployment whose media settings are `settings`. */
|
||||
function deps(settings: object | null, ciphertext: string): VoiceDeps {
|
||||
return {
|
||||
prisma: {
|
||||
spaceVoicePreference: { findFirst: async () => null },
|
||||
integrationProviderConfig: {
|
||||
findUnique: async () => (settings ? { id: "media", ciphertext } : null),
|
||||
},
|
||||
},
|
||||
secrets: { load: () => JSON.stringify(settings) },
|
||||
} as unknown as VoiceDeps;
|
||||
}
|
||||
|
||||
describe("dictation through the self-hosted transcription server", () => {
|
||||
let server: Server;
|
||||
let baseUrl = "";
|
||||
const uploads: string[] = [];
|
||||
|
||||
beforeAll(async () => {
|
||||
server = createServer((request, response) => {
|
||||
const chunks: Buffer[] = [];
|
||||
request.on("data", (chunk: Buffer) => chunks.push(chunk));
|
||||
request.on("end", () => {
|
||||
uploads.push(`${request.url} ${Buffer.concat(chunks).toString("latin1")}`);
|
||||
response.setHeader("content-type", "application/json");
|
||||
response.end(JSON.stringify({ text: " Remind me to call Ada. " }));
|
||||
});
|
||||
});
|
||||
await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve));
|
||||
baseUrl = `http://127.0.0.1:${(server.address() as AddressInfo).port}/v1`;
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await new Promise((resolve) => server.close(resolve));
|
||||
});
|
||||
|
||||
it("transcribes when no voice provider can", async () => {
|
||||
const result = await transcribeVoice(
|
||||
deps({ comfyui: null, transcription: { baseUrl } }, "media-a"),
|
||||
actor,
|
||||
{ audio: new Uint8Array([1, 2, 3]), mimeType: "audio/webm;codecs=opus" },
|
||||
);
|
||||
expect(result).toEqual({ text: "Remind me to call Ada." });
|
||||
expect(uploads[0]).toContain("/v1/audio/transcriptions");
|
||||
expect(uploads[0]).toContain('filename="audio.webm"');
|
||||
});
|
||||
|
||||
it("keeps asking for a voice key when no server is set up either", async () => {
|
||||
await expect(
|
||||
transcribeVoice(deps(null, "none"), actor, {
|
||||
audio: new Uint8Array([1]),
|
||||
mimeType: "audio/webm",
|
||||
}),
|
||||
).rejects.toBeInstanceOf(NoVoiceConfigured);
|
||||
});
|
||||
|
||||
it("refuses an empty recording before uploading it", async () => {
|
||||
const before = uploads.length;
|
||||
await expect(
|
||||
transcribeVoice(deps({ comfyui: null, transcription: { baseUrl } }, "media-b"), actor, {
|
||||
audio: new Uint8Array(),
|
||||
mimeType: "audio/webm",
|
||||
}),
|
||||
).rejects.toThrow("That recording is empty or too large.");
|
||||
expect(uploads).toHaveLength(before);
|
||||
});
|
||||
|
||||
it("reports server dictation in the voice status", () => {
|
||||
expect(toVoiceStatus(null).transcribe).toBe(false);
|
||||
expect(toVoiceStatus(null, { mediaTranscription: true })).toMatchObject({
|
||||
configured: false,
|
||||
transcribe: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
@ -1,10 +1,12 @@
|
||||
import { ORPCError } from "@orpc/server";
|
||||
import type { AdapterContext } from "@rakazo/adapter-kit";
|
||||
import type { EncryptedSecretStore } from "@rakazo/adapters";
|
||||
import {
|
||||
createOpenAiCompatibleTranscriptionProvider,
|
||||
createVoiceProvider,
|
||||
type EncryptedSecretStore,
|
||||
isVoiceProviderId,
|
||||
listVoiceCatalog,
|
||||
loadMediaSettings,
|
||||
MAX_SPEAK_CHARS,
|
||||
MAX_TRANSCRIBE_BYTES,
|
||||
NoVoiceConfigured,
|
||||
@ -51,12 +53,20 @@ export function catalogEntry(provider: string) {
|
||||
return voiceCatalogEntry(provider);
|
||||
}
|
||||
|
||||
export function toVoiceStatus(cred: { provider: string; voiceId: string } | null): VoiceStatus {
|
||||
/** The deployment's self-hosted transcription server, when the owner set one up. */
|
||||
export async function mediaTranscriptionSettings(deps: VoiceDeps) {
|
||||
return (await loadMediaSettings(deps).catch(() => null))?.transcription ?? null;
|
||||
}
|
||||
|
||||
export function toVoiceStatus(
|
||||
cred: { provider: string; voiceId: string } | null,
|
||||
options: { mediaTranscription?: boolean } = {},
|
||||
): VoiceStatus {
|
||||
const entry = cred ? catalogEntry(cred.provider) : undefined;
|
||||
return {
|
||||
configured: Boolean(cred),
|
||||
ready: Boolean(cred?.voiceId),
|
||||
transcribe: Boolean(entry?.transcribe && cred),
|
||||
transcribe: Boolean(entry?.transcribe && cred) || Boolean(options.mediaTranscription),
|
||||
provider: cred?.provider ?? null,
|
||||
voiceId: cred?.voiceId ?? "",
|
||||
};
|
||||
@ -277,9 +287,22 @@ export async function transcribeVoice(
|
||||
input: { audio: Uint8Array; mimeType: string; signal?: AbortSignal },
|
||||
) {
|
||||
const loaded = await loadDefaultVoiceCredential(deps, actor);
|
||||
if (!loaded) throw new NoVoiceConfigured("key");
|
||||
const provider = createVoiceProvider(loaded.cred.provider);
|
||||
if (!provider.transcribe) {
|
||||
const provider = loaded ? createVoiceProvider(loaded.cred.provider) : null;
|
||||
if (!loaded || !provider?.transcribe) {
|
||||
// Without a voice provider that transcribes, the self-hosted media server can.
|
||||
const server = await mediaTranscriptionSettings(deps);
|
||||
if (server) {
|
||||
if (input.audio.byteLength === 0 || input.audio.byteLength > MAX_TRANSCRIBE_BYTES) {
|
||||
throw new ORPCError("BAD_REQUEST", { message: "That recording is empty or too large." });
|
||||
}
|
||||
const transcript = await createOpenAiCompatibleTranscriptionProvider(server).transcribe({
|
||||
audio: input.audio,
|
||||
mimeType: input.mimeType || "audio/webm",
|
||||
signal: input.signal,
|
||||
});
|
||||
return { text: transcript.text };
|
||||
}
|
||||
if (!loaded) throw new NoVoiceConfigured("key");
|
||||
throw new ORPCError("BAD_REQUEST", {
|
||||
message: "This voice provider does not transcribe audio. Use on-device dictation instead.",
|
||||
});
|
||||
|
||||
28
apps/api/src/web-origin.test.ts
Normal file
28
apps/api/src/web-origin.test.ts
Normal file
@ -0,0 +1,28 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { requestWebOrigin } from "./web-origin.js";
|
||||
|
||||
const configured = "http://127.0.0.1:5173";
|
||||
|
||||
describe("requestWebOrigin", () => {
|
||||
it("follows a browser on loopback or a private network", () => {
|
||||
expect(requestWebOrigin("http://192.168.0.20:5173", configured)).toBe(
|
||||
"http://192.168.0.20:5173",
|
||||
);
|
||||
expect(requestWebOrigin("http://10.0.0.5:5173", configured)).toBe("http://10.0.0.5:5173");
|
||||
expect(requestWebOrigin("http://mac.local:5173", configured)).toBe("http://mac.local:5173");
|
||||
expect(requestWebOrigin("http://localhost:5173", configured)).toBe("http://localhost:5173");
|
||||
});
|
||||
|
||||
it("keeps WEB_ORIGIN for public hosts, other schemes, and missing origins", () => {
|
||||
expect(requestWebOrigin("https://attacker.example", configured)).toBe(configured);
|
||||
expect(requestWebOrigin("rakazo://app", configured)).toBe(configured);
|
||||
expect(requestWebOrigin("null", configured)).toBe(configured);
|
||||
expect(requestWebOrigin(undefined, configured)).toBe(configured);
|
||||
});
|
||||
|
||||
it("accepts the configured origin even when it is public", () => {
|
||||
expect(requestWebOrigin("https://evoo.example", "https://evoo.example")).toBe(
|
||||
"https://evoo.example",
|
||||
);
|
||||
});
|
||||
});
|
||||
30
apps/api/src/web-origin.ts
Normal file
30
apps/api/src/web-origin.ts
Normal file
@ -0,0 +1,30 @@
|
||||
/** Hosts the API already trusts as browser origins: loopback and private networks. */
|
||||
export function isPrivateOrLocalHost(host: string): boolean {
|
||||
if (isLoopbackHost(host)) return true;
|
||||
if (host.endsWith(".local")) return true;
|
||||
if (/^192\.168\.\d{1,3}\.\d{1,3}$/.test(host)) return true;
|
||||
if (/^10\.\d{1,3}\.\d{1,3}\.\d{1,3}$/.test(host)) return true;
|
||||
if (/^172\.(1[6-9]|2\d|3[01])\.\d{1,3}\.\d{1,3}$/.test(host)) return true;
|
||||
if (/^100\.(6[4-9]|[7-9]\d|1[01]\d|12[0-7])\.\d{1,3}\.\d{1,3}$/.test(host)) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
export function isLoopbackHost(host: string): boolean {
|
||||
return host === "localhost" || host === "127.0.0.1" || host === "::1" || host === "[::1]";
|
||||
}
|
||||
|
||||
/** The web origin that browser-facing URLs (OAuth returns, screen links) should
|
||||
* point at for this request. A browser opened over a loopback or private-network
|
||||
* address gets its own origin back so a phone on the LAN is not sent to
|
||||
* 127.0.0.1; any other caller gets the configured WEB_ORIGIN. */
|
||||
export function requestWebOrigin(origin: string | undefined, webOrigin: string): string {
|
||||
if (!origin) return webOrigin;
|
||||
try {
|
||||
const url = new URL(origin);
|
||||
if (url.protocol !== "http:" && url.protocol !== "https:") return webOrigin;
|
||||
if (url.origin === new URL(webOrigin).origin) return url.origin;
|
||||
return isPrivateOrLocalHost(url.hostname) ? url.origin : webOrigin;
|
||||
} catch {
|
||||
return webOrigin;
|
||||
}
|
||||
}
|
||||
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([
|
||||
"browserPreview",
|
||||
"files",
|
||||
"localSettings",
|
||||
"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,
|
||||
} from "./auto-update.js";
|
||||
import { openBrowserAuth } from "./browser-auth.js";
|
||||
import { BrowserPreviewController } from "./browser-preview.js";
|
||||
import { DOCKER_INSTALL_LINKS, isDesktopSetupLink, runDocker } from "./docker-cli.js";
|
||||
import { requestLocalSettings } from "./local-settings.js";
|
||||
import {
|
||||
@ -1130,6 +1131,35 @@ app.whenReady().then(async () => {
|
||||
},
|
||||
);
|
||||
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) => {
|
||||
if (!fromMainWindow(event)) return null;
|
||||
const result = await dialog.showOpenDialog(windowFrom(event)!, {
|
||||
|
||||
@ -21,6 +21,22 @@ contextBridge.exposeInMainWorld("rakazoDesktop", {
|
||||
download: () => ipcRenderer.invoke("desktop.update.download"),
|
||||
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: {
|
||||
open: (url) => ipcRenderer.invoke("desktop.oauth.open", url),
|
||||
cancel: (url) => ipcRenderer.invoke("desktop.oauth.cancel", url),
|
||||
|
||||
@ -32,6 +32,7 @@ describe("desktop preload bridge", () => {
|
||||
expect(globalName).toBe("rakazoDesktop");
|
||||
expect(bridge.platform).toBe("linux");
|
||||
expect(Object.keys(bridge).sort()).toEqual([
|
||||
"browserPreview",
|
||||
"files",
|
||||
"localSettings",
|
||||
"oauth",
|
||||
@ -46,6 +47,18 @@ describe("desktop preload bridge", () => {
|
||||
"toggleMaximize",
|
||||
]);
|
||||
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.oauth.open?.("https://provider.example.com/authorize");
|
||||
@ -58,6 +71,8 @@ describe("desktop preload bridge", () => {
|
||||
await bridge.update.check();
|
||||
await bridge.update.download();
|
||||
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([
|
||||
"desktop.files.pickDirectory",
|
||||
"desktop.oauth.open",
|
||||
@ -70,6 +85,8 @@ describe("desktop preload bridge", () => {
|
||||
"desktop.update.check",
|
||||
"desktop.update.download",
|
||||
"desktop.update.install",
|
||||
"desktop.browserPreview.navigate",
|
||||
"desktop.browserPreview.show",
|
||||
]);
|
||||
});
|
||||
|
||||
@ -77,6 +94,7 @@ describe("desktop preload bridge", () => {
|
||||
const { exposeInMainWorld } = runPreload("preload.cjs");
|
||||
const [, bridge] = exposeInMainWorld.mock.calls[0] as [string, Record<string, unknown>];
|
||||
expect(Object.keys(bridge).sort()).toEqual([
|
||||
"browserPreview",
|
||||
"files",
|
||||
"localSettings",
|
||||
"oauth",
|
||||
|
||||
@ -4,7 +4,6 @@ import { useEffect, useMemo, useState } from "react";
|
||||
import { View } from "react-native";
|
||||
import { GestureHandlerRootView } from "react-native-gesture-handler";
|
||||
import { KeyboardProvider } from "react-native-keyboard-controller";
|
||||
import { AvatarStyleProvider } from "../components/avatar-style";
|
||||
import { ComputerUpdateProgress } from "../components/computer-update-progress";
|
||||
import { currentApiBase, loadApiBase, loadSessionToken, selectedSpaceId } from "../lib/api";
|
||||
import { loadAppearancePreference, mobileTokens } from "../lib/appearance";
|
||||
@ -57,74 +56,72 @@ export default function Layout() {
|
||||
<GestureHandlerRootView style={{ flex: 1 }}>
|
||||
<KeyboardProvider>
|
||||
{ready ? (
|
||||
<AvatarStyleProvider>
|
||||
<ThemeProvider value={navigationTheme}>
|
||||
<StatusBar style={resolved === "light" ? "dark" : "light"} />
|
||||
<Stack
|
||||
screenOptions={{
|
||||
headerStyle: { backgroundColor: navigationTheme.colors.background },
|
||||
headerTintColor: navigationTheme.colors.text,
|
||||
headerShadowVisible: false,
|
||||
headerBackButtonDisplayMode: "minimal",
|
||||
contentStyle: { backgroundColor: String(native.page) },
|
||||
<ThemeProvider value={navigationTheme}>
|
||||
<StatusBar style={resolved === "light" ? "dark" : "light"} />
|
||||
<Stack
|
||||
screenOptions={{
|
||||
headerStyle: { backgroundColor: navigationTheme.colors.background },
|
||||
headerTintColor: navigationTheme.colors.text,
|
||||
headerShadowVisible: false,
|
||||
headerBackButtonDisplayMode: "minimal",
|
||||
contentStyle: { backgroundColor: String(native.page) },
|
||||
}}
|
||||
>
|
||||
<Stack.Screen name="index" options={{ headerShown: false, title: "Rakazo" }} />
|
||||
<Stack.Screen name="sign-in" options={{ headerShown: false }} />
|
||||
<Stack.Screen
|
||||
name="integration-setup"
|
||||
options={{ title: t("Server integrations") }}
|
||||
/>
|
||||
<Stack.Screen name="ai-data-sharing" options={{ title: "AI data sharing" }} />
|
||||
<Stack.Screen name="account" options={{ title: t("Account") }} />
|
||||
<Stack.Screen
|
||||
name="change-password"
|
||||
options={{
|
||||
title: t("Change password"),
|
||||
presentation: "formSheet",
|
||||
sheetAllowedDetents: [0.6, 1],
|
||||
sheetGrabberVisible: true,
|
||||
}}
|
||||
>
|
||||
<Stack.Screen name="index" options={{ headerShown: false, title: "Rakazo" }} />
|
||||
<Stack.Screen name="sign-in" options={{ headerShown: false }} />
|
||||
<Stack.Screen
|
||||
name="integration-setup"
|
||||
options={{ title: t("Server integrations") }}
|
||||
/>
|
||||
<Stack.Screen name="ai-data-sharing" options={{ title: "AI data sharing" }} />
|
||||
<Stack.Screen name="account" options={{ title: t("Account") }} />
|
||||
<Stack.Screen
|
||||
name="change-password"
|
||||
options={{
|
||||
title: t("Change password"),
|
||||
presentation: "formSheet",
|
||||
sheetAllowedDetents: [0.6, 1],
|
||||
sheetGrabberVisible: true,
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen name="models" options={{ title: t("Models") }} />
|
||||
<Stack.Screen name="voice" options={{ title: t("Voice") }} />
|
||||
<Stack.Screen name="integrations" options={{ title: t("Integrations") }} />
|
||||
<Stack.Screen
|
||||
name="new"
|
||||
options={{
|
||||
title: t("New bot"),
|
||||
presentation: "modal",
|
||||
gestureEnabled: true,
|
||||
headerBackVisible: false,
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="new-group"
|
||||
options={{
|
||||
title: t("New group"),
|
||||
presentation: "modal",
|
||||
gestureEnabled: true,
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="new-space"
|
||||
options={{
|
||||
title: t("New space"),
|
||||
presentation: "modal",
|
||||
gestureEnabled: true,
|
||||
headerBackVisible: false,
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen name="group-thread" options={{ title: t("Group") }} />
|
||||
<Stack.Screen name="group-settings" options={{ title: t("Group settings") }} />
|
||||
<Stack.Screen name="bot-settings" options={{ title: t("Chat settings") }} />
|
||||
<Stack.Screen name="thread" options={{ title: t("Thread") }} />
|
||||
<Stack.Screen name="routine" options={{ title: t("Routine") }} />
|
||||
<Stack.Screen name="computer" options={{ title: t("Computer") }} />
|
||||
</Stack>
|
||||
<ComputerUpdateProgress />
|
||||
</ThemeProvider>
|
||||
</AvatarStyleProvider>
|
||||
/>
|
||||
<Stack.Screen name="models" options={{ title: t("Models") }} />
|
||||
<Stack.Screen name="voice" options={{ title: t("Voice") }} />
|
||||
<Stack.Screen name="integrations" options={{ title: t("Integrations") }} />
|
||||
<Stack.Screen
|
||||
name="new"
|
||||
options={{
|
||||
title: t("New bot"),
|
||||
presentation: "modal",
|
||||
gestureEnabled: true,
|
||||
headerBackVisible: false,
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="new-group"
|
||||
options={{
|
||||
title: t("New group"),
|
||||
presentation: "modal",
|
||||
gestureEnabled: true,
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="new-space"
|
||||
options={{
|
||||
title: t("New space"),
|
||||
presentation: "modal",
|
||||
gestureEnabled: true,
|
||||
headerBackVisible: false,
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen name="group-thread" options={{ title: t("Group") }} />
|
||||
<Stack.Screen name="group-settings" options={{ title: t("Group settings") }} />
|
||||
<Stack.Screen name="bot-settings" options={{ title: t("Chat settings") }} />
|
||||
<Stack.Screen name="thread" options={{ title: t("Thread") }} />
|
||||
<Stack.Screen name="routine" options={{ title: t("Routine") }} />
|
||||
<Stack.Screen name="computer" options={{ title: t("Computer") }} />
|
||||
</Stack>
|
||||
<ComputerUpdateProgress />
|
||||
</ThemeProvider>
|
||||
) : (
|
||||
<View style={{ flex: 1, backgroundColor: String(native.page) }} />
|
||||
)}
|
||||
|
||||
@ -1,4 +1,3 @@
|
||||
import type { AvatarStyle } from "@rakazo/contracts";
|
||||
import { useLocalSearchParams, useRouter } from "expo-router";
|
||||
import { useEffect, useState } from "react";
|
||||
import {
|
||||
@ -15,8 +14,6 @@ import {
|
||||
View,
|
||||
} from "react-native";
|
||||
import { SafeAreaView } from "react-native-safe-area-context";
|
||||
import { useAvatarStyle } from "../components/avatar-style";
|
||||
import { BotAvatar } from "../components/bot-avatar";
|
||||
import type { MobileBot, MobileMe } from "../lib/api";
|
||||
import {
|
||||
currentApiBase,
|
||||
@ -61,8 +58,6 @@ export default function Account() {
|
||||
const [localeSaving, setLocaleSaving] = useState(false);
|
||||
const [localeError, setLocaleError] = useState<string | null>(null);
|
||||
const [pending, setPending] = useState(false);
|
||||
const [avatarPending, setAvatarPending] = useState(false);
|
||||
const [avatarError, setAvatarError] = useState<string | null>(null);
|
||||
const [notifications, setNotifications] = useState<LiveNotificationSettings>(
|
||||
DEFAULT_LIVE_NOTIFICATION_SETTINGS,
|
||||
);
|
||||
@ -76,7 +71,6 @@ export default function Account() {
|
||||
inputTokens: number;
|
||||
outputTokens: number;
|
||||
} | null>(null);
|
||||
const { avatarStyle, updateAvatarStyle } = useAvatarStyle();
|
||||
const appearance = getCachedAppearancePreference();
|
||||
const styles = useThemedStyles(createAccountStyles);
|
||||
const versionInfo = getAppVersionInfo();
|
||||
@ -127,19 +121,6 @@ export default function Account() {
|
||||
}
|
||||
}
|
||||
|
||||
async function selectAvatarStyle(next: AvatarStyle) {
|
||||
if (next === avatarStyle) return;
|
||||
setAvatarPending(true);
|
||||
setAvatarError(null);
|
||||
try {
|
||||
await updateAvatarStyle(next);
|
||||
} catch {
|
||||
setAvatarError(t("Couldn't update avatars"));
|
||||
} finally {
|
||||
setAvatarPending(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSignOut() {
|
||||
setPending(true);
|
||||
setError(null);
|
||||
@ -260,7 +241,7 @@ export default function Account() {
|
||||
<Text style={styles.chevron}>›</Text>
|
||||
</Pressable>
|
||||
|
||||
<View accessibilityLabel={t("Appearance")} style={styles.avatarSection}>
|
||||
<View accessibilityLabel={t("Appearance")} style={styles.appearanceSection}>
|
||||
<Text style={styles.settingsTitle}>{t("Appearance")}</Text>
|
||||
<View style={styles.appearanceOptions}>
|
||||
{(
|
||||
@ -292,40 +273,6 @@ export default function Account() {
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View accessibilityLabel={t("Avatar style")} style={styles.avatarSection}>
|
||||
<Text style={styles.settingsTitle}>{t("Avatars")}</Text>
|
||||
<View style={styles.avatarOptions}>
|
||||
{(["robot", "organic"] as const).map((style) => {
|
||||
const selected = avatarStyle === style;
|
||||
const styleLabel = style === "robot" ? t("Robot") : t("Organic");
|
||||
return (
|
||||
<Pressable
|
||||
key={style}
|
||||
accessibilityLabel={t("{style} avatars", { style: styleLabel })}
|
||||
accessibilityRole="button"
|
||||
accessibilityState={{ selected, disabled: avatarPending }}
|
||||
disabled={avatarPending}
|
||||
onPress={() => void selectAvatarStyle(style)}
|
||||
style={({ pressed }) => [
|
||||
styles.avatarOption,
|
||||
selected && styles.avatarOptionSelected,
|
||||
pressed && styles.pressed,
|
||||
]}
|
||||
>
|
||||
<BotAvatar
|
||||
color={style === "robot" ? "#8B5CF6" : "#D62F8B"}
|
||||
identity="avatar-preview"
|
||||
size={42}
|
||||
variant={style}
|
||||
/>
|
||||
<Text style={styles.avatarLabel}>{styleLabel}</Text>
|
||||
</Pressable>
|
||||
);
|
||||
})}
|
||||
</View>
|
||||
{avatarError ? <Text style={styles.error}>{avatarError}</Text> : null}
|
||||
</View>
|
||||
|
||||
<Pressable
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={t("Language")}
|
||||
@ -649,7 +596,7 @@ function createAccountStyles() {
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
},
|
||||
avatarSection: {
|
||||
appearanceSection: {
|
||||
borderRadius: 16,
|
||||
backgroundColor: native.fill,
|
||||
padding: 18,
|
||||
@ -677,29 +624,6 @@ function createAccountStyles() {
|
||||
fontSize: 14,
|
||||
fontWeight: "600",
|
||||
},
|
||||
avatarOptions: {
|
||||
flexDirection: "row",
|
||||
gap: 12,
|
||||
},
|
||||
avatarOption: {
|
||||
flex: 1,
|
||||
minHeight: 86,
|
||||
borderRadius: 14,
|
||||
borderWidth: StyleSheet.hairlineWidth,
|
||||
borderColor: native.tertiaryLabel,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
gap: 8,
|
||||
},
|
||||
avatarOptionSelected: {
|
||||
borderColor: native.label,
|
||||
backgroundColor: native.fillPressed,
|
||||
},
|
||||
avatarLabel: {
|
||||
color: native.label,
|
||||
fontSize: 14,
|
||||
fontWeight: "600",
|
||||
},
|
||||
settingsTitle: {
|
||||
color: native.label,
|
||||
fontSize: 17,
|
||||
|
||||
@ -12,6 +12,7 @@ import { useEffect, useMemo, useState } from "react";
|
||||
import { Pressable, ScrollView, Text, TextInput, View } from "react-native";
|
||||
import { BotAvatar } from "../components/bot-avatar";
|
||||
import { ComputerModePicker } from "../components/computer-mode-picker";
|
||||
import { OoAtelier } from "../components/oo-atelier";
|
||||
import {
|
||||
type MobileBot,
|
||||
type MobileMe,
|
||||
@ -348,33 +349,9 @@ export default function BotSettingsScreen() {
|
||||
textAlignVertical: "top",
|
||||
}}
|
||||
/>
|
||||
<Text style={{ color: tokens.mutedForeground, marginTop: 16, fontSize: 14 }}>
|
||||
{t("Color")}
|
||||
</Text>
|
||||
<ScrollView
|
||||
horizontal
|
||||
showsHorizontalScrollIndicator={false}
|
||||
contentContainerStyle={{ gap: 10, marginTop: 8 }}
|
||||
accessibilityRole="radiogroup"
|
||||
>
|
||||
{BOT_COLORS.map((option, index) => (
|
||||
<Pressable
|
||||
key={option}
|
||||
accessibilityRole="radio"
|
||||
accessibilityLabel={t("Color {number}", { number: index + 1 })}
|
||||
accessibilityState={{ checked: color === option }}
|
||||
onPress={() => setColor(option)}
|
||||
style={{
|
||||
width: 36,
|
||||
height: 36,
|
||||
borderRadius: 18,
|
||||
backgroundColor: option,
|
||||
borderWidth: 3,
|
||||
borderColor: color === option ? tokens.foreground : "transparent",
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</ScrollView>
|
||||
<View style={{ marginTop: 4 }}>
|
||||
<OoAtelier value={color} identity={bot?.id ?? ""} onChange={setColor} />
|
||||
</View>
|
||||
<ComputerModePicker value={computerMode} onChange={setComputerMode} />
|
||||
<Pressable
|
||||
accessibilityRole="button"
|
||||
|
||||
@ -1,3 +1,4 @@
|
||||
import type { AutopilotRecent } from "@rakazo/contracts";
|
||||
import {
|
||||
normalizeCreateBotProfile,
|
||||
type RunActivityRow,
|
||||
@ -46,6 +47,7 @@ import {
|
||||
selectSpace,
|
||||
} from "../lib/api";
|
||||
import { mobileTokens, resolveMobileAppearance } from "../lib/appearance";
|
||||
import { autopilotSummary } from "../lib/autopilot";
|
||||
import { allowFocusPrompt, scheduleFocusPrompt } from "../lib/focus-prompt";
|
||||
import { t, useI18n } from "../lib/i18n";
|
||||
import { botTag, filterBots, formatThreadTime, userInitials } from "../lib/inbox";
|
||||
@ -87,6 +89,7 @@ export default function Home() {
|
||||
const [botSections, setBotSections] = useState<MobileBotSection[]>([]);
|
||||
const [spaces, setSpaces] = useState<MobileSpace[]>([]);
|
||||
const [me, setMe] = useState<MobileMe | null>(null);
|
||||
const [autopilots, setAutopilots] = useState<AutopilotRecent[]>([]);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [ready, setReady] = useState(false);
|
||||
const [hasSession, setHasSession] = useState(false);
|
||||
@ -131,9 +134,10 @@ export default function Home() {
|
||||
const requestId = ++inboxRequestId.current;
|
||||
setError(null);
|
||||
try {
|
||||
const [navigation, nextMe] = await Promise.all([
|
||||
const [navigation, nextMe, recentAutopilots] = await Promise.all([
|
||||
rpc<MobileSpaceNavigation>("spaces/list"),
|
||||
rpc<MobileMe>("me"),
|
||||
rpc<{ items: AutopilotRecent[] }>("autopilot/recent").catch(() => null),
|
||||
]);
|
||||
if (requestId !== inboxRequestId.current) return;
|
||||
if (!(await selectInitialSpace(nextMe.spaceId))) {
|
||||
@ -145,6 +149,7 @@ export default function Home() {
|
||||
setGroups(navigation.current.groups);
|
||||
setSpaces(navigation.spaces);
|
||||
setMe(nextMe);
|
||||
if (recentAutopilots) setAutopilots(recentAutopilots.items);
|
||||
} catch (err) {
|
||||
if (requestId !== inboxRequestId.current) return;
|
||||
setError(err instanceof Error ? err.message : t("Could not load bots"));
|
||||
@ -543,11 +548,15 @@ export default function Home() {
|
||||
/>
|
||||
}
|
||||
ListHeaderComponent={
|
||||
activityMode &&
|
||||
!searching &&
|
||||
!query.trim() &&
|
||||
(activity.active.length > 0 || activity.recent.length > 0) ? (
|
||||
<ActivitySection activity={activity} bots={bots} />
|
||||
!searching && !query.trim() ? (
|
||||
<>
|
||||
{autopilots.length > 0 ? (
|
||||
<AutopilotSection autopilots={autopilots} bots={bots} groups={groups} />
|
||||
) : null}
|
||||
{activityMode && (activity.active.length > 0 || activity.recent.length > 0) ? (
|
||||
<ActivitySection activity={activity} bots={bots} />
|
||||
) : null}
|
||||
</>
|
||||
) : null
|
||||
}
|
||||
ListEmptyComponent={
|
||||
@ -744,6 +753,67 @@ function ActivitySection({
|
||||
);
|
||||
}
|
||||
|
||||
/** The night's work, like Today on web: each autopilot that runs, waits, or ended within a day. */
|
||||
function AutopilotSection({
|
||||
autopilots,
|
||||
bots,
|
||||
groups,
|
||||
}: {
|
||||
autopilots: AutopilotRecent[];
|
||||
bots: MobileBot[];
|
||||
groups: MobileGroup[];
|
||||
}) {
|
||||
const styles = useThemedStyles(createHomeStyles);
|
||||
const { t } = useI18n();
|
||||
const router = useRouter();
|
||||
const botsById = useMemo(() => new Map(bots.map((bot) => [bot.id, bot])), [bots]);
|
||||
const open = (autopilot: AutopilotRecent) =>
|
||||
void openMobileSpace(autopilot.spaceId, () =>
|
||||
router.push(
|
||||
autopilot.groupId
|
||||
? {
|
||||
pathname: "/group-thread",
|
||||
params: {
|
||||
groupId: autopilot.groupId,
|
||||
name: groups.find((group) => group.id === autopilot.groupId)?.name ?? t("Group"),
|
||||
},
|
||||
}
|
||||
: {
|
||||
pathname: "/thread",
|
||||
params: {
|
||||
botId: autopilot.botId,
|
||||
name: botsById.get(autopilot.botId)?.name ?? "",
|
||||
},
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
return (
|
||||
<View style={styles.activitySection} testID="home-autopilot">
|
||||
<Text style={styles.sectionHeading}>{t("Autopilot")}</Text>
|
||||
{autopilots.map((autopilot) => {
|
||||
const { detail, trail } = autopilotSummary(autopilot);
|
||||
return (
|
||||
<ConversationRow
|
||||
key={autopilot.id}
|
||||
title={autopilot.goal}
|
||||
preview={detail}
|
||||
time={trail}
|
||||
accessibilityLabel={`${autopilot.goal}, ${trail}`}
|
||||
onPress={() => open(autopilot)}
|
||||
avatar={
|
||||
<BotAvatar
|
||||
identity={autopilot.botId}
|
||||
color={botsById.get(autopilot.botId)?.color ?? FALLBACK_COLOR}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function ActivityRow({
|
||||
run,
|
||||
bot,
|
||||
|
||||
@ -28,7 +28,11 @@ export default function NewSpace() {
|
||||
return;
|
||||
}
|
||||
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) {
|
||||
setError(reason instanceof Error ? reason.message : t("Could not create space"));
|
||||
setPending(false);
|
||||
|
||||
@ -1,26 +1,34 @@
|
||||
import { ChatMarkdown } from "@rakazo/chat-ui/native";
|
||||
import type {
|
||||
AgentSkillCatalogEntry,
|
||||
CardAct,
|
||||
CardBlock,
|
||||
Connection,
|
||||
ConnectionCatalogItem,
|
||||
MessageBlock,
|
||||
Routine,
|
||||
} from "@rakazo/contracts";
|
||||
import {
|
||||
AUTOPILOT_LIMITS,
|
||||
canReactToThreadMessage,
|
||||
MESSAGE_REACTIONS,
|
||||
type MessageReaction,
|
||||
} from "@rakazo/contracts";
|
||||
import type { AutopilotUntilChoice } from "@rakazo/core";
|
||||
import {
|
||||
abortableDelay,
|
||||
attachmentsForThread,
|
||||
autopilotUntil,
|
||||
buildComposerMentionOptions,
|
||||
type ComposerMention,
|
||||
cloudAgentHttpsUrl,
|
||||
deriveWorkspaceFocus,
|
||||
isApprovalAskBlock,
|
||||
isRoutineEventMessage,
|
||||
isRunTerminalEvent,
|
||||
isSecretAskBlock,
|
||||
latestAnswerableAskMessageId,
|
||||
liveScreenRunId,
|
||||
mentionChipKey,
|
||||
projectMessageReactions,
|
||||
resolveComposerSendPlan,
|
||||
@ -59,24 +67,32 @@ import { useReducedMotion } from "react-native-reanimated";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { AppConnectCard } from "../components/AppConnectCard";
|
||||
import { AskActions } from "../components/AskActions";
|
||||
import type { AutopilotAction } from "../components/autopilot-line";
|
||||
import { AutopilotLine } from "../components/autopilot-line";
|
||||
import { AutopilotTimePicker } from "../components/autopilot-time-picker";
|
||||
import { BotAvatar } from "../components/bot-avatar";
|
||||
import {
|
||||
MarkdownArtifactPreview,
|
||||
type MarkdownArtifactPreviewTarget,
|
||||
} from "../components/markdown-artifact-preview";
|
||||
import { NativeSymbol } from "../components/native-symbol";
|
||||
import { RoutineEventLine } from "../components/routine-event-line";
|
||||
import { ThreadCard } from "../components/thread-card";
|
||||
import type {
|
||||
MobileBot,
|
||||
MobileGroup,
|
||||
MobileMessage,
|
||||
MobileMessagePage,
|
||||
MobileSnapshot,
|
||||
} from "../lib/api";
|
||||
import {
|
||||
applyMobileThreadEvent,
|
||||
blockText,
|
||||
copyableMobileMessageText,
|
||||
currentApiBase,
|
||||
loadSessionToken,
|
||||
type MobileBot,
|
||||
type MobileGroup,
|
||||
type MobileMessage,
|
||||
type MobileMessagePage,
|
||||
type MobileSnapshot,
|
||||
mergeMobileSnapshot,
|
||||
messageAttachments,
|
||||
messagingProviderLabel,
|
||||
prependMobileMessagePage,
|
||||
rpc,
|
||||
@ -230,6 +246,7 @@ function Thread() {
|
||||
const colorScheme = useResolvedAppearance();
|
||||
const tokens = mobileTokens();
|
||||
const [botActionsOpen, setBotActionsOpen] = useState(false);
|
||||
const [autopilotTimeFor, setAutopilotTimeFor] = useState<((time: string) => void) | null>(null);
|
||||
const { t } = useI18n();
|
||||
const navigation = useNavigation();
|
||||
const router = useRouter();
|
||||
@ -288,6 +305,8 @@ function Thread() {
|
||||
? { botId }
|
||||
: undefined;
|
||||
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 [draft, setDraft] = useState("");
|
||||
const [mentionQuery, setMentionQuery] = useState<string | null>(null);
|
||||
@ -860,6 +879,7 @@ function Thread() {
|
||||
}
|
||||
expandedHistoryThread.current = null;
|
||||
historyEpoch.current += 1;
|
||||
setScreenRunId(null);
|
||||
const abort = new AbortController();
|
||||
void (async () => {
|
||||
// Pending search jumps load the around-page separately; avoid replacing it with latest.
|
||||
@ -885,6 +905,8 @@ function Thread() {
|
||||
(event) => {
|
||||
cursor = Math.max(cursor, event.seq ?? -1);
|
||||
retryMs = 250;
|
||||
const screenRun = groupId ? null : liveScreenRunId(event);
|
||||
if (screenRun) setScreenRunId(screenRun);
|
||||
if (
|
||||
event.type === "thread.progress" ||
|
||||
event.type === "agent.tool.called" ||
|
||||
@ -1215,6 +1237,35 @@ function Thread() {
|
||||
[botId, groupId],
|
||||
);
|
||||
|
||||
const actOnCard = useCallback(
|
||||
async (message: MobileMessage, cardId: string, act: CardAct) => {
|
||||
const targetBotId = botId;
|
||||
const targetGroupId = groupId;
|
||||
if (!targetBotId && !targetGroupId) return;
|
||||
await rpc("threads/act", {
|
||||
...(targetGroupId ? { groupId: targetGroupId } : { botId: targetBotId! }),
|
||||
messageId: message.id,
|
||||
cardId,
|
||||
act,
|
||||
clientNonce: newClientNonce(),
|
||||
});
|
||||
if (isCurrentTarget(targetBotId, targetGroupId)) await refresh();
|
||||
},
|
||||
[botId, groupId],
|
||||
);
|
||||
|
||||
const actOnAutopilot = useCallback(
|
||||
async (action: AutopilotAction) => {
|
||||
const targetBotId = botId;
|
||||
const targetGroupId = groupId;
|
||||
if (!targetBotId && !targetGroupId) return;
|
||||
const target = targetGroupId ? { groupId: targetGroupId } : { botId: targetBotId! };
|
||||
await rpc(action === "stop" ? "autopilot/stop" : "autopilot/resume", target);
|
||||
if (isCurrentTarget(targetBotId, targetGroupId)) await refresh();
|
||||
},
|
||||
[botId, groupId],
|
||||
);
|
||||
|
||||
const openBot = useCallback(
|
||||
(id: string, botName: string) =>
|
||||
router.push({ pathname: "/thread", params: { botId: id, name: botName } }),
|
||||
@ -1231,15 +1282,56 @@ function Thread() {
|
||||
);
|
||||
|
||||
function showAttachMenu() {
|
||||
Alert.alert(t("Attach"), undefined, [
|
||||
{
|
||||
text: t("Photo library"),
|
||||
onPress: () => void addAttachments(pickFromLibrary),
|
||||
},
|
||||
{ text: t("Camera"), onPress: () => void addAttachments(takePhoto) },
|
||||
{ text: t("File"), onPress: () => void addAttachments(pickDocuments) },
|
||||
{ text: t("Cancel"), style: "cancel" },
|
||||
]);
|
||||
// The autopilot takes the typed order, so it is offered once there is one.
|
||||
const order = draft.trim();
|
||||
presentMessageActionSheet({
|
||||
title: t("Attach"),
|
||||
actions: [
|
||||
{ text: t("Photo library"), onPress: () => void addAttachments(pickFromLibrary) },
|
||||
{ text: t("Camera"), onPress: () => void addAttachments(takePhoto) },
|
||||
{ text: t("File"), onPress: () => void addAttachments(pickDocuments) },
|
||||
...(order ? [{ text: t("Autopilot"), onPress: () => askAutopilotUntil(order) }] : []),
|
||||
],
|
||||
colorScheme,
|
||||
cancel: t("Cancel"),
|
||||
more: t("More"),
|
||||
});
|
||||
}
|
||||
|
||||
function askAutopilotUntil(order: string) {
|
||||
const start = (choice: AutopilotUntilChoice) => {
|
||||
const targetBotId = botId;
|
||||
const targetGroupId = groupId;
|
||||
if (!targetBotId && !targetGroupId) return;
|
||||
const target = targetGroupId ? { groupId: targetGroupId } : { botId: targetBotId! };
|
||||
const until = autopilotUntil(choice, new Date());
|
||||
void rpc("autopilot/start", {
|
||||
...target,
|
||||
goal: order.slice(0, AUTOPILOT_LIMITS.goal),
|
||||
...(until ? { until: until.toISOString() } : {}),
|
||||
})
|
||||
.then(async () => {
|
||||
setDraft("");
|
||||
if (isCurrentTarget(targetBotId, targetGroupId)) await refresh();
|
||||
})
|
||||
.catch((err) =>
|
||||
Alert.alert(t("Autopilot"), err instanceof Error ? err.message : t("Try again.")),
|
||||
);
|
||||
};
|
||||
presentMessageActionSheet({
|
||||
title: t("Until when?"),
|
||||
actions: [
|
||||
{ text: t("Until done"), onPress: () => start({ kind: "done" }) },
|
||||
{ text: t("Until tomorrow morning"), onPress: () => start({ kind: "morning" }) },
|
||||
{
|
||||
text: t("Until a time"),
|
||||
onPress: () => setAutopilotTimeFor(() => (time: string) => start({ kind: "time", time })),
|
||||
},
|
||||
],
|
||||
colorScheme,
|
||||
cancel: t("Cancel"),
|
||||
more: t("More"),
|
||||
});
|
||||
}
|
||||
|
||||
async function addAttachments(
|
||||
@ -1271,6 +1363,23 @@ function Thread() {
|
||||
}
|
||||
|
||||
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 liveMessages = useMemo(() => [...visibleMessages].reverse(), [visibleMessages]);
|
||||
const messagesById = useMemo(
|
||||
@ -1447,6 +1556,8 @@ function Thread() {
|
||||
}
|
||||
canAnswer={message.id === answerableAskMessageId}
|
||||
onAnswer={answerMessage}
|
||||
onCardAct={actOnCard}
|
||||
onAutopilot={actOnAutopilot}
|
||||
onOpenBot={openBot}
|
||||
onPreviewMarkdown={setMarkdownPreview}
|
||||
actionProps={actionProps}
|
||||
@ -1668,6 +1779,43 @@ function Thread() {
|
||||
) : null}
|
||||
</View>
|
||||
<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 ? (
|
||||
<View
|
||||
style={{
|
||||
@ -2065,6 +2213,15 @@ function Thread() {
|
||||
) : null}
|
||||
</View>
|
||||
</View>
|
||||
{autopilotTimeFor ? (
|
||||
<AutopilotTimePicker
|
||||
onPick={(time) => {
|
||||
setAutopilotTimeFor(null);
|
||||
autopilotTimeFor(time);
|
||||
}}
|
||||
onClose={() => setAutopilotTimeFor(null)}
|
||||
/>
|
||||
) : null}
|
||||
<Modal
|
||||
visible={botActionsOpen}
|
||||
transparent
|
||||
@ -2243,7 +2400,11 @@ function previewMessageText(message: MobileMessage): string {
|
||||
.join(" ")
|
||||
.trim();
|
||||
if (text) return text;
|
||||
if (message.blocks.some((block) => block.kind === "image" || block.kind === "file")) {
|
||||
if (
|
||||
message.blocks.some((block) =>
|
||||
["image", "file", "video", "audio", "media"].includes(block.kind),
|
||||
)
|
||||
) {
|
||||
return t("Attachment");
|
||||
}
|
||||
return t("Message");
|
||||
@ -2280,6 +2441,8 @@ const MessageBubble = memo(function MessageBubble({
|
||||
replyPreview,
|
||||
canAnswer,
|
||||
onAnswer,
|
||||
onCardAct,
|
||||
onAutopilot,
|
||||
onOpenBot,
|
||||
onPreviewMarkdown,
|
||||
actionProps,
|
||||
@ -2293,6 +2456,8 @@ const MessageBubble = memo(function MessageBubble({
|
||||
replyPreview?: MobileMessage;
|
||||
canAnswer: boolean;
|
||||
onAnswer: (message: MobileMessage, answer: string) => Promise<void>;
|
||||
onCardAct: (message: MobileMessage, cardId: string, act: CardAct) => Promise<void>;
|
||||
onAutopilot: (action: AutopilotAction) => Promise<void>;
|
||||
onOpenBot: (botId: string, name: string) => void;
|
||||
onPreviewMarkdown: (target: MarkdownArtifactPreviewTarget) => void;
|
||||
actionProps: MessageActionProps;
|
||||
@ -2332,6 +2497,39 @@ const MessageBubble = memo(function MessageBubble({
|
||||
</View>
|
||||
);
|
||||
}
|
||||
const cards = message.blocks.filter(
|
||||
(block): block is CardBlock => block.kind === "card" && Boolean(block.sections),
|
||||
);
|
||||
if (cards.length) {
|
||||
return (
|
||||
<View style={{ gap: 8, width: "100%" }}>
|
||||
{cards.map((card, index) => {
|
||||
const cardId = card.id;
|
||||
return (
|
||||
<ThreadCard
|
||||
key={cardId ?? index}
|
||||
block={card}
|
||||
createdAt={message.createdAt}
|
||||
onAct={cardId ? (act) => onCardAct(message, cardId, act) : undefined}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
if (isRoutineEventMessage(message.blocks)) {
|
||||
return (
|
||||
<View style={{ width: "100%" }}>
|
||||
{message.blocks.map((block, index) =>
|
||||
block.kind === "event" ? (
|
||||
<RoutineEventLine key={index} block={block} />
|
||||
) : block.kind === "autopilot" ? (
|
||||
<AutopilotLine key={index} block={block} onAction={onAutopilot} />
|
||||
) : null,
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
const handoff = message.blocks.find((block) => block.kind === "handoff");
|
||||
if (handoff) {
|
||||
const from = memberName(members, handoff.fromBotId) ?? t("bot");
|
||||
@ -2676,9 +2874,7 @@ const MessageBubble = memo(function MessageBubble({
|
||||
</View>
|
||||
);
|
||||
}
|
||||
const attachments = message.blocks.filter(
|
||||
(block) => block.kind === "image" || block.kind === "file",
|
||||
);
|
||||
const attachments = messageAttachments(message.blocks);
|
||||
const caption = message.blocks
|
||||
.flatMap((block) => {
|
||||
if (block.kind === "channel_message" && block.text) {
|
||||
@ -2686,6 +2882,7 @@ const MessageBubble = memo(function MessageBubble({
|
||||
`${messagingProviderLabel(block.provider, block.transport)} · ${block.fromLabel}: ${block.text}`,
|
||||
];
|
||||
}
|
||||
if (block.kind === "media") return [block.title];
|
||||
return block.kind === "text" && block.text ? [block.text] : [];
|
||||
})
|
||||
.join("\n");
|
||||
@ -2797,7 +2994,8 @@ const MessageBubble = memo(function MessageBubble({
|
||||
fontSize: 15,
|
||||
}}
|
||||
>
|
||||
📎 {attachment.name ?? t("File")}
|
||||
{attachment.kind === "video" ? "🎬" : attachment.kind === "audio" ? "🎵" : "📎"}{" "}
|
||||
{attachment.name ?? t("File")}
|
||||
</Text>
|
||||
{attachment.size ? (
|
||||
<Text
|
||||
|
||||
183
apps/mobile/components/autopilot-line.tsx
Normal file
183
apps/mobile/components/autopilot-line.tsx
Normal file
@ -0,0 +1,183 @@
|
||||
import type { AutopilotBlock } from "@rakazo/contracts";
|
||||
import { formatAutopilotDuration } from "@rakazo/core";
|
||||
import { useState } from "react";
|
||||
import { Pressable, Text, View } from "react-native";
|
||||
import { dateLocaleForUi, useI18n } from "../lib/i18n";
|
||||
import { useMobileTokens } from "../lib/native";
|
||||
import { NativeSymbol } from "./native-symbol";
|
||||
|
||||
export type AutopilotAction = "stop" | "resume";
|
||||
|
||||
const ICONS = {
|
||||
"autopilot.started": { ios: "play.fill", android: "play" },
|
||||
"autopilot.paused": { ios: "pause.fill", android: "pause" },
|
||||
"autopilot.stopped": { ios: "stop.fill", android: "stop" },
|
||||
"autopilot.finished": { ios: "checkmark.circle", android: "checkmark-circle-outline" },
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* One quiet line when the autopilot starts, pauses, stops or finishes. The start line counts the
|
||||
* steps and folds open to the latest checkpoints; a finished autopilot's result reads as a card.
|
||||
*/
|
||||
export function AutopilotLine({
|
||||
block,
|
||||
onAction,
|
||||
}: {
|
||||
block: AutopilotBlock;
|
||||
onAction?: (action: AutopilotAction) => Promise<void>;
|
||||
}) {
|
||||
const tokens = useMobileTokens();
|
||||
const { t, locale } = useI18n();
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [failed, setFailed] = useState(false);
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const open = !block.ended && Boolean(onAction);
|
||||
const muted = { color: tokens.mutedForeground, fontSize: 13.5 };
|
||||
|
||||
async function act(action: AutopilotAction) {
|
||||
if (!onAction) return;
|
||||
setBusy(true);
|
||||
setFailed(false);
|
||||
try {
|
||||
await onAction(action);
|
||||
} catch {
|
||||
setFailed(true);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
const parts: string[] = [];
|
||||
let label = t("Autopilot");
|
||||
if (block.event === "autopilot.started") {
|
||||
parts.push(block.goal);
|
||||
if (block.until) {
|
||||
const until = new Intl.DateTimeFormat(dateLocaleForUi(locale), {
|
||||
hour: "numeric",
|
||||
minute: "2-digit",
|
||||
}).format(new Date(block.until));
|
||||
parts.push(t("until {until}", { until }));
|
||||
}
|
||||
if (block.focus) parts.push(block.focus);
|
||||
if (block.allowed?.length) {
|
||||
parts.push(t("allowed: {allowed}", { allowed: block.allowed.join(", ") }));
|
||||
}
|
||||
} else if (block.event === "autopilot.paused") {
|
||||
label = t("Autopilot paused");
|
||||
if (block.text) parts.push(block.text);
|
||||
} else {
|
||||
label = block.event === "autopilot.stopped" ? t("Autopilot stopped") : t("Autopilot finished");
|
||||
if (block.durationMs !== undefined) parts.push(formatAutopilotDuration(block.durationMs));
|
||||
}
|
||||
const icon = ICONS[block.event];
|
||||
const checkpoints = block.event === "autopilot.started" ? (block.checkpoints ?? []) : [];
|
||||
const latest = checkpoints.at(-1)?.text;
|
||||
const time = new Intl.DateTimeFormat(dateLocaleForUi(locale), {
|
||||
hour: "numeric",
|
||||
minute: "2-digit",
|
||||
});
|
||||
const link = (text: string, action: AutopilotAction) => (
|
||||
<Pressable
|
||||
accessibilityRole="button"
|
||||
hitSlop={10}
|
||||
disabled={busy}
|
||||
onPress={() => void act(action)}
|
||||
style={{ opacity: busy ? 0.5 : 1 }}
|
||||
>
|
||||
<Text style={[muted, { fontWeight: "600" }]}>{text}</Text>
|
||||
</Pressable>
|
||||
);
|
||||
|
||||
return (
|
||||
<View style={{ width: "100%", paddingVertical: 4, alignItems: "center", gap: 4 }}>
|
||||
<View
|
||||
style={{
|
||||
flexDirection: "row",
|
||||
flexWrap: "wrap",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
columnGap: 6,
|
||||
rowGap: 2,
|
||||
}}
|
||||
>
|
||||
<NativeSymbol
|
||||
ios={icon.ios}
|
||||
android={icon.android}
|
||||
size={13}
|
||||
color={tokens.mutedForeground}
|
||||
/>
|
||||
<Text style={[muted, { color: tokens.foreground, fontWeight: "500" }]}>{label}</Text>
|
||||
{parts.map((part, index) => (
|
||||
<Text key={index} style={muted}>
|
||||
· {part}
|
||||
</Text>
|
||||
))}
|
||||
{block.steps && block.event === "autopilot.started" ? (
|
||||
<>
|
||||
<Text style={muted}>·</Text>
|
||||
<Pressable
|
||||
accessibilityRole="button"
|
||||
accessibilityState={{ expanded }}
|
||||
hitSlop={10}
|
||||
onPress={() => setExpanded((value) => !value)}
|
||||
>
|
||||
<Text style={[muted, { fontWeight: "600" }]}>
|
||||
{block.steps === 1 ? t("1 step") : t("{count} steps", { count: block.steps })}
|
||||
</Text>
|
||||
</Pressable>
|
||||
{latest && !expanded ? (
|
||||
<Text style={muted} numberOfLines={1}>
|
||||
· {t("last: {latest}", { latest })}
|
||||
</Text>
|
||||
) : null}
|
||||
</>
|
||||
) : null}
|
||||
{open && block.event === "autopilot.paused" ? (
|
||||
<>
|
||||
<Text style={muted}>·</Text>
|
||||
{link(t("Continue"), "resume")}
|
||||
</>
|
||||
) : null}
|
||||
{open && (block.event === "autopilot.started" || block.event === "autopilot.paused") ? (
|
||||
<>
|
||||
<Text style={muted}>·</Text>
|
||||
{link(failed ? t("Try again") : t("Stop"), "stop")}
|
||||
</>
|
||||
) : null}
|
||||
</View>
|
||||
{expanded && checkpoints.length > 0 ? (
|
||||
<View style={{ width: "100%", maxWidth: 420, gap: 4, paddingHorizontal: 16 }}>
|
||||
{checkpoints.map((checkpoint, index) => (
|
||||
<View key={`${checkpoint.at}-${index}`} style={{ flexDirection: "row", gap: 8 }}>
|
||||
<Text
|
||||
style={[muted, { width: 64, textAlign: "right", fontVariant: ["tabular-nums"] }]}
|
||||
>
|
||||
{time.format(new Date(checkpoint.at))}
|
||||
</Text>
|
||||
<Text style={{ flex: 1, color: tokens.foreground, fontSize: 13.5 }}>
|
||||
{checkpoint.text}
|
||||
</Text>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
) : null}
|
||||
{block.event === "autopilot.finished" && block.text ? (
|
||||
<View
|
||||
style={{
|
||||
width: "100%",
|
||||
maxWidth: 520,
|
||||
marginTop: 4,
|
||||
borderRadius: 12,
|
||||
borderWidth: 1,
|
||||
borderColor: tokens.border,
|
||||
backgroundColor: tokens.card,
|
||||
paddingHorizontal: 14,
|
||||
paddingVertical: 10,
|
||||
}}
|
||||
>
|
||||
<Text style={{ color: tokens.foreground, fontSize: 14 }}>{block.text}</Text>
|
||||
</View>
|
||||
) : null}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
71
apps/mobile/components/autopilot-time-picker.tsx
Normal file
71
apps/mobile/components/autopilot-time-picker.tsx
Normal file
@ -0,0 +1,71 @@
|
||||
import { DateTimePicker } from "@expo/ui/community/datetime-picker";
|
||||
import { useState } from "react";
|
||||
import { Modal, Platform, Pressable, Text, View } from "react-native";
|
||||
import { autopilotTimeOfDay } from "../lib/autopilot";
|
||||
import { useI18n } from "../lib/i18n";
|
||||
import { useMobileTokens, useResolvedAppearance } from "../lib/native";
|
||||
|
||||
/**
|
||||
* The native time picker for "Until a time". Android opens its clock dialog; iOS shows the wheel in
|
||||
* a sheet with Start. Either way the caller gets "HH:MM" and unmounts the picker.
|
||||
*/
|
||||
export function AutopilotTimePicker({
|
||||
onPick,
|
||||
onClose,
|
||||
}: {
|
||||
onPick: (time: string) => void;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const tokens = useMobileTokens();
|
||||
const colorScheme = useResolvedAppearance();
|
||||
const { t } = useI18n();
|
||||
const [value, setValue] = useState(() => {
|
||||
const morning = new Date();
|
||||
morning.setHours(8, 0, 0, 0);
|
||||
return morning;
|
||||
});
|
||||
|
||||
if (Platform.OS === "android") {
|
||||
return (
|
||||
<DateTimePicker
|
||||
mode="time"
|
||||
value={value}
|
||||
onValueChange={(_event, date) => onPick(autopilotTimeOfDay(date))}
|
||||
onDismiss={onClose}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal visible animationType="slide" presentationStyle="pageSheet" onRequestClose={onClose}>
|
||||
<View style={{ flex: 1, backgroundColor: tokens.background, padding: 20, gap: 12 }}>
|
||||
<View
|
||||
style={{ flexDirection: "row", alignItems: "center", justifyContent: "space-between" }}
|
||||
>
|
||||
<Pressable accessibilityRole="button" onPress={onClose} hitSlop={12}>
|
||||
<Text style={{ color: tokens.mutedForeground, fontSize: 17 }}>{t("Cancel")}</Text>
|
||||
</Pressable>
|
||||
<Text style={{ color: tokens.foreground, fontSize: 17, fontWeight: "600" }}>
|
||||
{t("Until when?")}
|
||||
</Text>
|
||||
<Pressable
|
||||
accessibilityRole="button"
|
||||
onPress={() => onPick(autopilotTimeOfDay(value))}
|
||||
hitSlop={12}
|
||||
>
|
||||
<Text style={{ color: tokens.foreground, fontSize: 17, fontWeight: "600" }}>
|
||||
{t("Start")}
|
||||
</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
<DateTimePicker
|
||||
mode="time"
|
||||
display="spinner"
|
||||
value={value}
|
||||
themeVariant={colorScheme}
|
||||
onValueChange={(_event, date) => setValue(date)}
|
||||
/>
|
||||
</View>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@ -1,52 +0,0 @@
|
||||
import type { AvatarStyle, Me } from "@rakazo/contracts";
|
||||
import { usePathname } from "expo-router";
|
||||
import { createContext, type ReactNode, useContext, useEffect, useRef, useState } from "react";
|
||||
import { rpc } from "../lib/api";
|
||||
|
||||
const AvatarStyleContext = createContext<{
|
||||
avatarStyle: AvatarStyle;
|
||||
updateAvatarStyle: (avatarStyle: AvatarStyle) => Promise<void>;
|
||||
}>({
|
||||
avatarStyle: "robot",
|
||||
updateAvatarStyle: async () => undefined,
|
||||
});
|
||||
|
||||
export function AvatarStyleProvider({ children }: { children: ReactNode }) {
|
||||
const [avatarStyle, setAvatarStyle] = useState<AvatarStyle>("robot");
|
||||
const pathname = usePathname();
|
||||
const requestIdRef = useRef(0);
|
||||
const updatePromiseRef = useRef<Promise<void> | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const requestId = ++requestIdRef.current;
|
||||
void rpc<Me>("me")
|
||||
.then((me) => {
|
||||
if (requestId !== requestIdRef.current) return;
|
||||
setAvatarStyle(me.avatarStyle);
|
||||
})
|
||||
.catch(() => undefined);
|
||||
}, [pathname]);
|
||||
|
||||
function updateAvatarStyle(next: AvatarStyle): Promise<void> {
|
||||
if (updatePromiseRef.current) return updatePromiseRef.current;
|
||||
const requestId = ++requestIdRef.current;
|
||||
const update = rpc<Me>("preferences/update", { avatarStyle: next })
|
||||
.then((me) => {
|
||||
if (requestId !== requestIdRef.current) return;
|
||||
setAvatarStyle(me.avatarStyle);
|
||||
})
|
||||
.finally(() => {
|
||||
updatePromiseRef.current = null;
|
||||
});
|
||||
updatePromiseRef.current = update;
|
||||
return update;
|
||||
}
|
||||
|
||||
return (
|
||||
<AvatarStyleContext value={{ avatarStyle, updateAvatarStyle }}>{children}</AvatarStyleContext>
|
||||
);
|
||||
}
|
||||
|
||||
export function useAvatarStyle() {
|
||||
return useContext(AvatarStyleContext);
|
||||
}
|
||||
@ -1,88 +1,76 @@
|
||||
import type { AvatarStyle } from "@rakazo/contracts";
|
||||
import { ACTIVE_RUN_STATUSES, avatarIdentitySeed, organicAvatarPath } from "@rakazo/core";
|
||||
import { memo, useEffect } from "react";
|
||||
import { View } from "react-native";
|
||||
import Animated, {
|
||||
cancelAnimation,
|
||||
Easing,
|
||||
useAnimatedProps,
|
||||
useAnimatedStyle,
|
||||
useReducedMotion,
|
||||
useSharedValue,
|
||||
withRepeat,
|
||||
withTiming,
|
||||
} from "react-native-reanimated";
|
||||
import Svg, { G, Path, Rect } from "react-native-svg";
|
||||
import { workingAvatarDuration, workingAvatarFrame } from "../lib/avatar-motion";
|
||||
import type { OoMoment } from "@rakazo/core";
|
||||
import {
|
||||
ACTIVE_RUN_STATUSES,
|
||||
DEFAULT_OO_AVATAR,
|
||||
decodeOoAvatar,
|
||||
OO_SUCCESS_SECONDS,
|
||||
ooIdentityShapeIndex,
|
||||
ooMomentAfterStatus,
|
||||
ooMomentForStatus,
|
||||
resolveOoColor,
|
||||
} from "@rakazo/core";
|
||||
import { memo, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { Image, View } from "react-native";
|
||||
import { useI18n } from "../lib/i18n";
|
||||
import { useAvatarStyle } from "./avatar-style";
|
||||
import { NativeSymbol } from "./native-symbol";
|
||||
import { OoFigure } from "./oo-figure";
|
||||
|
||||
const AnimatedRect = Animated.createAnimatedComponent(Rect);
|
||||
/** A short moment after a status change, such as joy when a run finishes. */
|
||||
function useOoStatusMoment(status?: string): OoMoment | null {
|
||||
const previous = useRef(status);
|
||||
const [moment, setMoment] = useState<OoMoment | null>(null);
|
||||
useEffect(() => {
|
||||
const next = ooMomentAfterStatus(previous.current, status);
|
||||
previous.current = status;
|
||||
setMoment(next);
|
||||
if (!next) return;
|
||||
const timer = setTimeout(() => setMoment(null), OO_SUCCESS_SECONDS * 1000);
|
||||
return () => clearTimeout(timer);
|
||||
}, [status]);
|
||||
return moment;
|
||||
}
|
||||
|
||||
export const BotAvatar = memo(function BotAvatar({
|
||||
color,
|
||||
size = 54,
|
||||
status,
|
||||
identity,
|
||||
variant,
|
||||
moment,
|
||||
still,
|
||||
muted = false,
|
||||
}: {
|
||||
color: string;
|
||||
size?: number;
|
||||
status?: string;
|
||||
identity?: string;
|
||||
variant?: AvatarStyle;
|
||||
/** Explicit moment; wins over `status`. */
|
||||
moment?: OoMoment;
|
||||
still?: boolean;
|
||||
muted?: boolean;
|
||||
}) {
|
||||
const { t } = useI18n();
|
||||
const isWorking = ACTIVE_RUN_STATUSES.some((activeStatus) => activeStatus === status);
|
||||
const { avatarStyle } = useAvatarStyle();
|
||||
const visorW = Math.round(size * 0.68);
|
||||
const visorH = Math.round(size * 0.44);
|
||||
const eyeW = Math.max(3, Math.round(size * 0.11));
|
||||
const eyeH = Math.max(4, Math.round(size * 0.17));
|
||||
const gap = Math.max(3, Math.round(size * 0.11));
|
||||
const picture = color.startsWith("data:image/") ? color : null;
|
||||
const look = useMemo(() => decodeOoAvatar(color) ?? DEFAULT_OO_AVATAR, [color]);
|
||||
const seed = identity || (picture ? "agent" : color) || "agent";
|
||||
const body = resolveOoColor(seed, look.color).hex;
|
||||
const statusMoment = useOoStatusMoment(status);
|
||||
return (
|
||||
<View style={{ width: size, height: size }}>
|
||||
{(variant ?? avatarStyle) === "organic" ? (
|
||||
<OrganicAvatar color={color} identity={identity} size={size} isWorking={isWorking} />
|
||||
{picture ? (
|
||||
<Image
|
||||
source={{ uri: picture }}
|
||||
style={{ width: size, height: size, borderRadius: size / 2 }}
|
||||
/>
|
||||
) : (
|
||||
<View
|
||||
style={{
|
||||
width: size,
|
||||
height: size,
|
||||
borderRadius: size / 2,
|
||||
backgroundColor: color,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
}}
|
||||
>
|
||||
<View
|
||||
style={{
|
||||
width: visorW,
|
||||
height: visorH,
|
||||
borderRadius: Math.round(visorH * 0.52),
|
||||
backgroundColor: "#0C0C0E",
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
gap,
|
||||
}}
|
||||
>
|
||||
{[0, 1].map((eye) => (
|
||||
<View
|
||||
key={eye}
|
||||
style={{
|
||||
width: eyeW,
|
||||
height: eyeH,
|
||||
borderRadius: Math.max(2, Math.round(eyeW * 0.6)),
|
||||
backgroundColor: "#fff",
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</View>
|
||||
</View>
|
||||
<OoFigure
|
||||
avatar={look}
|
||||
color={body}
|
||||
fallbackShape={look.shape ?? ooIdentityShapeIndex(seed)}
|
||||
moment={moment ?? statusMoment ?? ooMomentForStatus(status)}
|
||||
size={size}
|
||||
still={still}
|
||||
/>
|
||||
)}
|
||||
{isWorking ? (
|
||||
<View
|
||||
@ -128,81 +116,3 @@ export const BotAvatar = memo(function BotAvatar({
|
||||
</View>
|
||||
);
|
||||
});
|
||||
|
||||
function OrganicAvatar({
|
||||
color,
|
||||
identity,
|
||||
size,
|
||||
isWorking,
|
||||
}: {
|
||||
color: string;
|
||||
identity?: string;
|
||||
size: number;
|
||||
isWorking: boolean;
|
||||
}) {
|
||||
const seed = avatarIdentitySeed(identity || color || "#8B5CF6");
|
||||
const progress = useSharedValue(0);
|
||||
const reducedMotion = useReducedMotion();
|
||||
|
||||
useEffect(() => {
|
||||
cancelAnimation(progress);
|
||||
progress.value = 0;
|
||||
if (isWorking && !reducedMotion) {
|
||||
progress.value = withRepeat(
|
||||
withTiming(1, {
|
||||
duration: workingAvatarDuration(seed),
|
||||
easing: Easing.linear,
|
||||
}),
|
||||
-1,
|
||||
);
|
||||
}
|
||||
return () => cancelAnimation(progress);
|
||||
}, [isWorking, progress, reducedMotion, seed]);
|
||||
|
||||
const bodyStyle = useAnimatedStyle(() => {
|
||||
const frame = workingAvatarFrame(seed, progress.value);
|
||||
return {
|
||||
transform: [
|
||||
{ translateX: (frame.translationX * size) / 120 },
|
||||
{ translateY: (frame.translationY * size) / 120 },
|
||||
{ rotate: `${frame.rotation}deg` },
|
||||
{ scaleX: frame.scaleX },
|
||||
{ scaleY: frame.scaleY },
|
||||
],
|
||||
};
|
||||
});
|
||||
const leftEyeProps = useAnimatedProps(() => {
|
||||
const frame = workingAvatarFrame(seed, progress.value);
|
||||
return { x: -14 + frame.eyeOffsetX, y: -12 + frame.eyeOffsetY };
|
||||
});
|
||||
const rightEyeProps = useAnimatedProps(() => {
|
||||
const frame = workingAvatarFrame(seed, progress.value);
|
||||
return { x: 7 + frame.eyeOffsetX, y: -12 + frame.eyeOffsetY };
|
||||
});
|
||||
|
||||
return (
|
||||
<View style={{ width: size, height: size }}>
|
||||
<Animated.View style={[{ width: size, height: size }, bodyStyle]}>
|
||||
<Svg width={size} height={size} viewBox="-60 -60 120 120">
|
||||
<Path d={organicAvatarPath(seed)} fill={color} />
|
||||
<G transform={`rotate(${(seed % 9) - 4})`}>
|
||||
<AnimatedRect
|
||||
animatedProps={leftEyeProps}
|
||||
width={7}
|
||||
height={24}
|
||||
rx={3.5}
|
||||
fill="#101014"
|
||||
/>
|
||||
<AnimatedRect
|
||||
animatedProps={rightEyeProps}
|
||||
width={7}
|
||||
height={24}
|
||||
rx={3.5}
|
||||
fill="#101014"
|
||||
/>
|
||||
</G>
|
||||
</Svg>
|
||||
</Animated.View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
173
apps/mobile/components/cooking-mode.tsx
Normal file
173
apps/mobile/components/cooking-mode.tsx
Normal file
@ -0,0 +1,173 @@
|
||||
import type { CardAct, CardSection } from "@rakazo/contracts";
|
||||
import { cardTimerRemaining, formatCardClock } from "@rakazo/core";
|
||||
import { useKeepAwake } from "expo-keep-awake";
|
||||
import { useEffect, useState } from "react";
|
||||
import { Modal, Pressable, SafeAreaView, StyleSheet, Text, View } from "react-native";
|
||||
import { mobileTokens } from "../lib/appearance";
|
||||
import { useI18n } from "../lib/i18n";
|
||||
import { NativeSymbol } from "./native-symbol";
|
||||
|
||||
type Steps = Extract<CardSection, { type: "steps" }>;
|
||||
|
||||
/** The current time, ticking every second while `active`. */
|
||||
export function useNow(active: boolean): number {
|
||||
const [now, setNow] = useState(() => Date.now());
|
||||
useEffect(() => {
|
||||
if (!active) return;
|
||||
setNow(Date.now());
|
||||
const timer = setInterval(() => setNow(Date.now()), 1000);
|
||||
return () => clearInterval(timer);
|
||||
}, [active]);
|
||||
return now;
|
||||
}
|
||||
|
||||
/**
|
||||
* A card's steps one at a time in full screen, large enough to read from the stove, with the
|
||||
* screen kept on. The step and any timer live on the card, so the web and a reload agree.
|
||||
*/
|
||||
export function CookingMode({
|
||||
title,
|
||||
steps,
|
||||
now,
|
||||
busy,
|
||||
onAct,
|
||||
onClose,
|
||||
}: {
|
||||
title: string;
|
||||
steps: Steps;
|
||||
now: number;
|
||||
busy: boolean;
|
||||
onAct: (act: CardAct) => Promise<void>;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
useKeepAwake();
|
||||
const tokens = mobileTokens();
|
||||
const { t } = useI18n();
|
||||
const index = Math.min(steps.current ?? 0, steps.steps.length - 1);
|
||||
const step = steps.steps[index];
|
||||
const last = index === steps.steps.length - 1;
|
||||
const remaining = cardTimerRemaining(step?.timerEndsAt, now);
|
||||
const running = remaining !== null && remaining > 0;
|
||||
const go = (next: number) => void onAct({ type: "step", sectionId: steps.id, index: next });
|
||||
|
||||
return (
|
||||
<Modal animationType="slide" presentationStyle="fullScreen" onRequestClose={onClose}>
|
||||
<SafeAreaView style={[styles.screen, { backgroundColor: tokens.background }]}>
|
||||
<View style={styles.top}>
|
||||
<Text numberOfLines={1} style={[styles.title, { color: tokens.mutedForeground }]}>
|
||||
{title}
|
||||
</Text>
|
||||
<Pressable
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={t("Close")}
|
||||
onPress={onClose}
|
||||
hitSlop={12}
|
||||
>
|
||||
<NativeSymbol ios="xmark" android="close" size={20} />
|
||||
</Pressable>
|
||||
</View>
|
||||
<View style={styles.middle}>
|
||||
<Text style={[styles.counter, { color: tokens.mutedForeground }]}>
|
||||
{t("Step {position} of {total}", {
|
||||
position: index + 1,
|
||||
total: steps.steps.length,
|
||||
})}
|
||||
</Text>
|
||||
<Text style={[styles.step, { color: tokens.foreground }]}>{step?.text}</Text>
|
||||
{step?.timerSeconds ? (
|
||||
<Pressable
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={running ? t("Stop timer") : t("Start timer")}
|
||||
disabled={busy}
|
||||
onPress={() =>
|
||||
void onAct({
|
||||
type: "timer",
|
||||
sectionId: steps.id,
|
||||
stepId: step.id,
|
||||
running: !running,
|
||||
})
|
||||
}
|
||||
style={[
|
||||
styles.timer,
|
||||
{
|
||||
borderColor: running ? tokens.primary : tokens.border,
|
||||
backgroundColor: running ? tokens.primary : "transparent",
|
||||
},
|
||||
]}
|
||||
>
|
||||
<NativeSymbol
|
||||
ios={running ? "pause.fill" : "play.fill"}
|
||||
android={running ? "pause" : "play"}
|
||||
size={20}
|
||||
color={running ? tokens.primaryForeground : tokens.foreground}
|
||||
/>
|
||||
<Text
|
||||
style={[
|
||||
styles.clock,
|
||||
{ color: running ? tokens.primaryForeground : tokens.foreground },
|
||||
]}
|
||||
>
|
||||
{formatCardClock(remaining ?? step.timerSeconds)}
|
||||
</Text>
|
||||
</Pressable>
|
||||
) : null}
|
||||
</View>
|
||||
<View style={styles.bottom}>
|
||||
<Pressable
|
||||
accessibilityRole="button"
|
||||
disabled={busy || index === 0}
|
||||
onPress={() => go(index - 1)}
|
||||
style={[styles.nav, { borderColor: tokens.border, opacity: index === 0 ? 0.4 : 1 }]}
|
||||
>
|
||||
<Text style={[styles.navText, { color: tokens.foreground }]}>{t("Back")}</Text>
|
||||
</Pressable>
|
||||
<Pressable
|
||||
accessibilityRole="button"
|
||||
disabled={busy && !last}
|
||||
onPress={() => (last ? onClose() : go(index + 1))}
|
||||
style={[styles.nav, { borderColor: tokens.primary, backgroundColor: tokens.primary }]}
|
||||
>
|
||||
<Text style={[styles.navText, { color: tokens.primaryForeground }]}>
|
||||
{last ? t("Done") : t("Next")}
|
||||
</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
</SafeAreaView>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
screen: { flex: 1, paddingHorizontal: 24 },
|
||||
top: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
gap: 12,
|
||||
paddingTop: 16,
|
||||
},
|
||||
title: { flex: 1, fontSize: 15, fontWeight: "600" },
|
||||
middle: { flex: 1, justifyContent: "center", gap: 28 },
|
||||
counter: { fontSize: 15 },
|
||||
step: { fontSize: 30, lineHeight: 40, fontWeight: "600" },
|
||||
timer: {
|
||||
alignSelf: "flex-start",
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: 10,
|
||||
borderWidth: 1,
|
||||
borderRadius: 16,
|
||||
paddingHorizontal: 18,
|
||||
paddingVertical: 12,
|
||||
},
|
||||
clock: { fontSize: 26, fontWeight: "600", fontVariant: ["tabular-nums"] },
|
||||
bottom: { flexDirection: "row", justifyContent: "space-between", gap: 12, paddingBottom: 16 },
|
||||
nav: {
|
||||
flex: 1,
|
||||
alignItems: "center",
|
||||
borderWidth: 1,
|
||||
borderRadius: 14,
|
||||
paddingVertical: 14,
|
||||
},
|
||||
navText: { fontSize: 16, fontWeight: "600" },
|
||||
});
|
||||
410
apps/mobile/components/oo-atelier.tsx
Normal file
410
apps/mobile/components/oo-atelier.tsx
Normal file
@ -0,0 +1,410 @@
|
||||
import type { OoAvatar } from "@rakazo/core";
|
||||
import {
|
||||
encodeOoAvatar,
|
||||
OO_ARMS,
|
||||
OO_BROWS,
|
||||
OO_CHEEKS,
|
||||
OO_CLASSIC_SHAPE_COUNT,
|
||||
OO_EYESHADOWS,
|
||||
OO_EYEWEAR,
|
||||
OO_HEADWEAR,
|
||||
OO_IRISES,
|
||||
OO_LASHES,
|
||||
OO_LINERS,
|
||||
OO_NECKWEAR,
|
||||
OO_PALETTE_HEXES,
|
||||
OO_SPARKLES,
|
||||
OO_SPECIES,
|
||||
ooLookForEditing,
|
||||
ooRandomAvatar,
|
||||
} from "@rakazo/core";
|
||||
import { useState } from "react";
|
||||
import { Pressable, ScrollView, Text, View } from "react-native";
|
||||
import { useI18n } from "../lib/i18n";
|
||||
import { useMobileTokens } from "../lib/native";
|
||||
import { OoFigure } from "./oo-figure";
|
||||
|
||||
const THUMB = 52;
|
||||
|
||||
/** Temperament in three taps; web offers the same range as sliders. */
|
||||
const ENERGY_STEPS = [0.75, 1, 1.35] as const;
|
||||
const PLAYFUL_STEPS = [0.2, 0.5, 0.85] as const;
|
||||
|
||||
function nearest(steps: readonly number[], value: number): number {
|
||||
return steps.reduce((best, step) =>
|
||||
Math.abs(step - value) < Math.abs(best - value) ? step : best,
|
||||
);
|
||||
}
|
||||
|
||||
function useLabels() {
|
||||
const { t } = useI18n();
|
||||
const none = t("None");
|
||||
return {
|
||||
species: {
|
||||
classic: t("Classic"),
|
||||
cat: t("Cat"),
|
||||
dog: t("Dog"),
|
||||
bunny: t("Bunny"),
|
||||
bear: t("Bear"),
|
||||
robot: t("Robot"),
|
||||
ghost: t("Ghost"),
|
||||
frog: t("Frog"),
|
||||
penguin: t("Penguin"),
|
||||
},
|
||||
lashes: { none, soft: t("Soft"), full: t("Full"), flick: t("Flick") },
|
||||
liner: { none, wing: t("Winged") },
|
||||
eyeshadow: { none, rose: t("Rose"), lilac: t("Lilac"), gold: t("Gold"), mint: t("Mint") },
|
||||
iris: {
|
||||
classic: t("Classic"),
|
||||
brown: t("Brown"),
|
||||
blue: t("Blue"),
|
||||
green: t("Green"),
|
||||
violet: t("Violet"),
|
||||
},
|
||||
sparkle: { dot: t("Dot"), star: t("Star"), heart: t("Heart") },
|
||||
brows: { auto: t("When needed"), fine: t("Fine"), bushy: t("Bushy") },
|
||||
cheeks: { none, pink: t("Pink"), peach: t("Peach"), freckles: t("Freckles") },
|
||||
eyewear: {
|
||||
none,
|
||||
round: t("Round"),
|
||||
nerd: t("Nerd"),
|
||||
sun: t("Sunglasses"),
|
||||
heart: t("Heart"),
|
||||
monocle: t("Monocle"),
|
||||
reading: t("Reading glasses"),
|
||||
},
|
||||
head: {
|
||||
none,
|
||||
chef: t("Chef's hat"),
|
||||
crown: t("Crown"),
|
||||
headphones: t("Headphones"),
|
||||
bow: t("Bow"),
|
||||
beanie: t("Beanie"),
|
||||
flower: t("Flower"),
|
||||
party: t("Party hat"),
|
||||
},
|
||||
neck: { none, bowtie: t("Bow tie"), scarf: t("Scarf"), bell: t("Bell") },
|
||||
arms: { glass: t("Glass"), stub: t("Stub"), paw: t("Paw"), robot: t("Robot"), none },
|
||||
energy: [t("Calm"), t("Steady"), t("Lively")],
|
||||
} as const;
|
||||
}
|
||||
|
||||
function Row<T extends string | number>({
|
||||
title,
|
||||
values,
|
||||
selected,
|
||||
label,
|
||||
look,
|
||||
patch,
|
||||
focus = "body",
|
||||
onSelect,
|
||||
}: {
|
||||
title: string;
|
||||
values: readonly T[];
|
||||
selected: T;
|
||||
label: (value: T) => string;
|
||||
look: OoAvatar;
|
||||
patch: (value: T) => Partial<OoAvatar>;
|
||||
focus?: "body" | "face";
|
||||
onSelect: (value: T) => void;
|
||||
}) {
|
||||
const tokens = useMobileTokens();
|
||||
return (
|
||||
<View style={{ marginTop: 14 }}>
|
||||
<Text style={{ color: tokens.mutedForeground, fontSize: 13 }}>{title}</Text>
|
||||
<ScrollView
|
||||
horizontal
|
||||
showsHorizontalScrollIndicator={false}
|
||||
contentContainerStyle={{ gap: 8, marginTop: 6 }}
|
||||
accessibilityRole="radiogroup"
|
||||
accessibilityLabel={title}
|
||||
>
|
||||
{values.map((value) => {
|
||||
const next = { ...look, ...patch(value) };
|
||||
const active = value === selected;
|
||||
return (
|
||||
<Pressable
|
||||
key={value}
|
||||
accessibilityRole="radio"
|
||||
accessibilityLabel={label(value)}
|
||||
accessibilityState={{ checked: active }}
|
||||
onPress={() => onSelect(value)}
|
||||
style={{
|
||||
width: THUMB + 8,
|
||||
height: THUMB + 8,
|
||||
borderRadius: 14,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
overflow: "hidden",
|
||||
backgroundColor: tokens.muted,
|
||||
borderWidth: 2,
|
||||
borderColor: active ? tokens.foreground : "transparent",
|
||||
}}
|
||||
>
|
||||
<OoFigure
|
||||
avatar={next}
|
||||
color={next.color}
|
||||
fallbackShape={next.shape ?? 0}
|
||||
moment="idle"
|
||||
size={THUMB}
|
||||
focus={focus}
|
||||
still
|
||||
/>
|
||||
</Pressable>
|
||||
);
|
||||
})}
|
||||
</ScrollView>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
/** The Oo look editor: species and colour first, everything else one tap further. */
|
||||
export function OoAtelier({
|
||||
value,
|
||||
identity,
|
||||
onChange,
|
||||
}: {
|
||||
value: string;
|
||||
identity: string;
|
||||
onChange: (value: string) => void;
|
||||
}) {
|
||||
const { t } = useI18n();
|
||||
const tokens = useMobileTokens();
|
||||
const labels = useLabels();
|
||||
const [moreOpen, setMoreOpen] = useState(false);
|
||||
const look = ooLookForEditing(value, identity);
|
||||
const set = <K extends keyof OoAvatar>(field: K, next: OoAvatar[K]) =>
|
||||
onChange(encodeOoAvatar({ ...look, [field]: next }));
|
||||
const chip = (active: boolean) => ({
|
||||
minHeight: 36,
|
||||
paddingHorizontal: 14,
|
||||
borderRadius: 18,
|
||||
justifyContent: "center" as const,
|
||||
backgroundColor: active ? tokens.foreground : tokens.muted,
|
||||
});
|
||||
const chipText = (active: boolean) => ({
|
||||
color: active ? tokens.background : tokens.foreground,
|
||||
fontSize: 14,
|
||||
});
|
||||
|
||||
return (
|
||||
<View>
|
||||
<Row
|
||||
title={t("Species")}
|
||||
values={OO_SPECIES}
|
||||
selected={look.species}
|
||||
label={(species) => labels.species[species]}
|
||||
look={look}
|
||||
patch={(species) => ({ species, arms: species === "robot" ? "robot" : look.arms })}
|
||||
onSelect={(species) =>
|
||||
onChange(
|
||||
encodeOoAvatar({
|
||||
...look,
|
||||
species,
|
||||
arms: species === "robot" ? "robot" : look.arms === "robot" ? "glass" : look.arms,
|
||||
}),
|
||||
)
|
||||
}
|
||||
/>
|
||||
{look.species === "classic" ? (
|
||||
<Row
|
||||
title={t("Shape")}
|
||||
values={Array.from({ length: OO_CLASSIC_SHAPE_COUNT }, (_, index) => index)}
|
||||
selected={look.shape ?? -1}
|
||||
label={(shape) => t("Shape {number}", { number: shape + 1 })}
|
||||
look={look}
|
||||
patch={(shape) => ({ shape })}
|
||||
onSelect={(shape) => set("shape", shape)}
|
||||
/>
|
||||
) : null}
|
||||
<Text style={{ color: tokens.mutedForeground, marginTop: 14, fontSize: 13 }}>
|
||||
{t("Color")}
|
||||
</Text>
|
||||
<ScrollView
|
||||
horizontal
|
||||
showsHorizontalScrollIndicator={false}
|
||||
contentContainerStyle={{ gap: 10, marginTop: 6 }}
|
||||
accessibilityRole="radiogroup"
|
||||
>
|
||||
{OO_PALETTE_HEXES.map((option, index) => {
|
||||
const checked = look.color.toLowerCase() === option.toLowerCase();
|
||||
return (
|
||||
<Pressable
|
||||
key={option}
|
||||
accessibilityRole="radio"
|
||||
accessibilityLabel={t("Color {number}", { number: index + 1 })}
|
||||
accessibilityState={{ checked }}
|
||||
onPress={() => set("color", option)}
|
||||
style={{
|
||||
width: 36,
|
||||
height: 36,
|
||||
borderRadius: 18,
|
||||
backgroundColor: option,
|
||||
borderWidth: 3,
|
||||
borderColor: checked ? tokens.foreground : tokens.border,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</ScrollView>
|
||||
<View style={{ flexDirection: "row", gap: 8, marginTop: 14 }}>
|
||||
<Pressable
|
||||
accessibilityRole="button"
|
||||
accessibilityState={{ expanded: moreOpen }}
|
||||
onPress={() => setMoreOpen((open) => !open)}
|
||||
style={chip(moreOpen)}
|
||||
>
|
||||
<Text style={chipText(moreOpen)}>{t("Customize")}</Text>
|
||||
</Pressable>
|
||||
<Pressable
|
||||
accessibilityRole="button"
|
||||
onPress={() => onChange(encodeOoAvatar(ooRandomAvatar()))}
|
||||
style={chip(false)}
|
||||
>
|
||||
<Text style={chipText(false)}>{t("Surprise me")}</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
{moreOpen ? (
|
||||
<View>
|
||||
<Row
|
||||
title={t("Lashes")}
|
||||
values={OO_LASHES}
|
||||
selected={look.lashes}
|
||||
label={(lashes) => labels.lashes[lashes]}
|
||||
look={look}
|
||||
patch={(lashes) => ({ lashes })}
|
||||
focus="face"
|
||||
onSelect={(lashes) => set("lashes", lashes)}
|
||||
/>
|
||||
<Row
|
||||
title={t("Eyeliner")}
|
||||
values={OO_LINERS}
|
||||
selected={look.liner}
|
||||
label={(liner) => labels.liner[liner]}
|
||||
look={look}
|
||||
patch={(liner) => ({ liner })}
|
||||
focus="face"
|
||||
onSelect={(liner) => set("liner", liner)}
|
||||
/>
|
||||
<Row
|
||||
title={t("Eyeshadow")}
|
||||
values={OO_EYESHADOWS}
|
||||
selected={look.eyeshadow}
|
||||
label={(eyeshadow) => labels.eyeshadow[eyeshadow]}
|
||||
look={look}
|
||||
patch={(eyeshadow) => ({ eyeshadow })}
|
||||
focus="face"
|
||||
onSelect={(eyeshadow) => set("eyeshadow", eyeshadow)}
|
||||
/>
|
||||
<Row
|
||||
title={t("Iris")}
|
||||
values={OO_IRISES}
|
||||
selected={look.iris}
|
||||
label={(iris) => labels.iris[iris]}
|
||||
look={look}
|
||||
patch={(iris) => ({ iris })}
|
||||
focus="face"
|
||||
onSelect={(iris) => set("iris", iris)}
|
||||
/>
|
||||
<Row
|
||||
title={t("Sparkle")}
|
||||
values={OO_SPARKLES}
|
||||
selected={look.sparkle}
|
||||
label={(sparkle) => labels.sparkle[sparkle]}
|
||||
look={look}
|
||||
patch={(sparkle) => ({ sparkle })}
|
||||
focus="face"
|
||||
onSelect={(sparkle) => set("sparkle", sparkle)}
|
||||
/>
|
||||
<Row
|
||||
title={t("Brows")}
|
||||
values={OO_BROWS}
|
||||
selected={look.brows}
|
||||
label={(brows) => labels.brows[brows]}
|
||||
look={look}
|
||||
patch={(brows) => ({ brows })}
|
||||
focus="face"
|
||||
onSelect={(brows) => set("brows", brows)}
|
||||
/>
|
||||
<Row
|
||||
title={t("Cheeks")}
|
||||
values={OO_CHEEKS}
|
||||
selected={look.cheeks}
|
||||
label={(cheeks) => labels.cheeks[cheeks]}
|
||||
look={look}
|
||||
patch={(cheeks) => ({ cheeks })}
|
||||
focus="face"
|
||||
onSelect={(cheeks) => set("cheeks", cheeks)}
|
||||
/>
|
||||
<Row
|
||||
title={t("Glasses")}
|
||||
values={OO_EYEWEAR}
|
||||
selected={look.eyewear}
|
||||
label={(eyewear) => labels.eyewear[eyewear]}
|
||||
look={look}
|
||||
patch={(eyewear) => ({ eyewear })}
|
||||
focus="face"
|
||||
onSelect={(eyewear) => set("eyewear", eyewear)}
|
||||
/>
|
||||
<Row
|
||||
title={t("Hat")}
|
||||
values={OO_HEADWEAR}
|
||||
selected={look.head}
|
||||
label={(head) => labels.head[head]}
|
||||
look={look}
|
||||
patch={(head) => ({ head })}
|
||||
onSelect={(head) => set("head", head)}
|
||||
/>
|
||||
<Row
|
||||
title={t("Neck")}
|
||||
values={OO_NECKWEAR}
|
||||
selected={look.neck}
|
||||
label={(neck) => labels.neck[neck]}
|
||||
look={look}
|
||||
patch={(neck) => ({ neck })}
|
||||
onSelect={(neck) => set("neck", neck)}
|
||||
/>
|
||||
<Row
|
||||
title={t("Arms")}
|
||||
values={OO_ARMS}
|
||||
selected={look.arms}
|
||||
label={(arms) => labels.arms[arms]}
|
||||
look={look}
|
||||
patch={(arms) => ({ arms })}
|
||||
onSelect={(arms) => set("arms", arms)}
|
||||
/>
|
||||
{(
|
||||
[
|
||||
["energy", t("Energy"), ENERGY_STEPS],
|
||||
["playful", t("Playful"), PLAYFUL_STEPS],
|
||||
] as const
|
||||
).map(([field, title, steps]) => {
|
||||
const current = nearest(steps, look[field]);
|
||||
return (
|
||||
<View key={field} style={{ marginTop: 14 }}>
|
||||
<Text style={{ color: tokens.mutedForeground, fontSize: 13 }}>{title}</Text>
|
||||
<View
|
||||
accessibilityRole="radiogroup"
|
||||
accessibilityLabel={title}
|
||||
style={{ flexDirection: "row", gap: 8, marginTop: 6 }}
|
||||
>
|
||||
{steps.map((step, index) => (
|
||||
<Pressable
|
||||
key={step}
|
||||
accessibilityRole="radio"
|
||||
accessibilityState={{ checked: step === current }}
|
||||
onPress={() => set(field, step)}
|
||||
style={chip(step === current)}
|
||||
>
|
||||
<Text style={chipText(step === current)}>{labels.energy[index]}</Text>
|
||||
</Pressable>
|
||||
))}
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
})}
|
||||
</View>
|
||||
) : null}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
222
apps/mobile/components/oo-figure.tsx
Normal file
222
apps/mobile/components/oo-figure.tsx
Normal file
@ -0,0 +1,222 @@
|
||||
import type { OoAvatar, OoFrame, OoLife, OoMoment, OoPaint, OoShape } from "@rakazo/core";
|
||||
import {
|
||||
buildOoRig,
|
||||
createOoLife,
|
||||
OO_LIST_MOMENTS,
|
||||
OO_RIG_INSET,
|
||||
OO_RIG_SIZE,
|
||||
OO_STILL_TIME,
|
||||
ooFrame,
|
||||
ooLodForSize,
|
||||
ooSeededRandom,
|
||||
} from "@rakazo/core";
|
||||
import type { ReactNode } from "react";
|
||||
import { useEffect, useId, useMemo, useRef, useState } from "react";
|
||||
import { useReducedMotion } from "react-native-reanimated";
|
||||
import Svg, {
|
||||
Circle,
|
||||
Defs,
|
||||
Ellipse,
|
||||
G,
|
||||
LinearGradient,
|
||||
Path,
|
||||
RadialGradient,
|
||||
Rect,
|
||||
Stop,
|
||||
} from "react-native-svg";
|
||||
|
||||
const VIEWBOX = `${OO_RIG_INSET} ${OO_RIG_INSET + 4} ${OO_RIG_SIZE - 2 * OO_RIG_INSET} ${OO_RIG_SIZE - 2 * OO_RIG_INSET}`;
|
||||
const FACE_VIEWBOX = "47 62 112 112";
|
||||
/** Native frames redraw the SVG tree, so they run at half the display rate. */
|
||||
const FRAME_SECONDS = 1 / 30;
|
||||
/** Above this size an Oo always moves; smaller ones only for list moments. */
|
||||
const LIVELY_SIZE = 56;
|
||||
|
||||
type Tick = (now: number) => void;
|
||||
const ticks = new Set<Tick>();
|
||||
let frameRequest = 0;
|
||||
|
||||
function loop(now: number) {
|
||||
for (const tick of ticks) tick(now / 1000);
|
||||
frameRequest = ticks.size ? requestAnimationFrame(loop) : 0;
|
||||
}
|
||||
|
||||
function subscribe(tick: Tick): () => void {
|
||||
ticks.add(tick);
|
||||
if (!frameRequest) frameRequest = requestAnimationFrame(loop);
|
||||
return () => {
|
||||
ticks.delete(tick);
|
||||
};
|
||||
}
|
||||
|
||||
function paintProps(paint: OoPaint, gradient: string) {
|
||||
const fill =
|
||||
paint.fill === "$body"
|
||||
? `url(#${gradient}-body)`
|
||||
: paint.fill === "$shine"
|
||||
? `url(#${gradient}-shine)`
|
||||
: paint.fill;
|
||||
return {
|
||||
fill: fill ?? (paint.stroke ? "none" : undefined),
|
||||
stroke: paint.stroke,
|
||||
strokeWidth: paint.strokeWidth,
|
||||
opacity: paint.opacity,
|
||||
fillOpacity: paint.fillOpacity,
|
||||
strokeOpacity: paint.strokeOpacity,
|
||||
strokeLinecap: paint.round ? ("round" as const) : undefined,
|
||||
strokeLinejoin: paint.round ? ("round" as const) : undefined,
|
||||
strokeDasharray: paint.dash,
|
||||
};
|
||||
}
|
||||
|
||||
function draw(
|
||||
shape: OoShape,
|
||||
key: number,
|
||||
gradient: string,
|
||||
frame: OoFrame,
|
||||
mouthColor: string,
|
||||
): ReactNode {
|
||||
switch (shape.type) {
|
||||
case "path":
|
||||
return <Path key={key} d={shape.d} {...paintProps(shape, gradient)} />;
|
||||
case "circle":
|
||||
return (
|
||||
<Circle
|
||||
key={key}
|
||||
cx={shape.cx}
|
||||
cy={shape.cy}
|
||||
r={shape.r}
|
||||
{...paintProps(shape, gradient)}
|
||||
/>
|
||||
);
|
||||
case "ellipse":
|
||||
return (
|
||||
<Ellipse
|
||||
key={key}
|
||||
cx={shape.cx}
|
||||
cy={shape.cy}
|
||||
rx={shape.rx}
|
||||
ry={shape.ry}
|
||||
{...paintProps(shape, gradient)}
|
||||
/>
|
||||
);
|
||||
case "rect":
|
||||
return (
|
||||
<Rect
|
||||
key={key}
|
||||
x={shape.x}
|
||||
y={shape.y}
|
||||
width={shape.width}
|
||||
height={shape.height}
|
||||
rx={shape.rx}
|
||||
{...paintProps(shape, gradient)}
|
||||
/>
|
||||
);
|
||||
case "group": {
|
||||
const { part, fx } = shape;
|
||||
if (part === "mouth") {
|
||||
return (
|
||||
<Path
|
||||
key={key}
|
||||
d={frame.mouth.d}
|
||||
fill={frame.mouth.filled ? mouthColor : "none"}
|
||||
stroke={mouthColor}
|
||||
strokeWidth={3.2}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (part === "tongue") {
|
||||
return frame.tongue ? <Path key={key} d={frame.tongue} fill="#FF7FA6" /> : null;
|
||||
}
|
||||
const state = fx
|
||||
? frame.fx(fx.kind, fx.index)
|
||||
: part && part !== "fx"
|
||||
? frame.parts[part]
|
||||
: undefined;
|
||||
if (state?.hidden ?? shape.hidden) return null;
|
||||
return (
|
||||
<G key={key} transform={state?.transform ?? shape.transform} opacity={state?.opacity}>
|
||||
{shape.children.map((child, index) => draw(child, index, gradient, frame, mouthColor))}
|
||||
</G>
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function OoFigure({
|
||||
avatar,
|
||||
color,
|
||||
fallbackShape,
|
||||
moment,
|
||||
size,
|
||||
still = false,
|
||||
focus = "body",
|
||||
}: {
|
||||
avatar: OoAvatar;
|
||||
color: string;
|
||||
fallbackShape: number;
|
||||
moment: OoMoment;
|
||||
size: number;
|
||||
still?: boolean;
|
||||
/** `face` crops to the eyes, for pickers of eye styles and eyewear. */
|
||||
focus?: "body" | "face";
|
||||
}) {
|
||||
const gradient = `oo${useId().replace(/[^a-zA-Z0-9-_]/g, "")}`;
|
||||
const lod = ooLodForSize(size);
|
||||
const rig = useMemo(
|
||||
() => buildOoRig({ avatar, color, fallbackShape, moment, lod }),
|
||||
[avatar, color, fallbackShape, moment, lod],
|
||||
);
|
||||
const reducedMotion = useReducedMotion();
|
||||
const lively =
|
||||
!still && !reducedMotion && (size > LIVELY_SIZE || OO_LIST_MOMENTS.includes(moment));
|
||||
const lifeRef = useRef<OoLife | null>(null);
|
||||
const [frame, setFrame] = useState<OoFrame | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
setFrame(null);
|
||||
if (!lively) return;
|
||||
if (!lifeRef.current) lifeRef.current = createOoLife();
|
||||
const life = lifeRef.current;
|
||||
const start = performance.now() / 1000 - Math.random() * 10;
|
||||
let last = 0;
|
||||
return subscribe((now) => {
|
||||
if (now - last < FRAME_SECONDS) return;
|
||||
last = now;
|
||||
setFrame(ooFrame({ rig, avatar, moment, t: now - start, life }));
|
||||
});
|
||||
}, [rig, avatar, moment, lively]);
|
||||
|
||||
const stillFrame = useMemo(
|
||||
() =>
|
||||
ooFrame({
|
||||
rig,
|
||||
avatar,
|
||||
moment,
|
||||
t: OO_STILL_TIME,
|
||||
life: createOoLife(ooSeededRandom(1)),
|
||||
still: true,
|
||||
}),
|
||||
[rig, avatar, moment],
|
||||
);
|
||||
const shown = frame ?? stillFrame;
|
||||
const { colors } = rig;
|
||||
return (
|
||||
<Svg width={size} height={size} viewBox={focus === "face" ? FACE_VIEWBOX : VIEWBOX}>
|
||||
<Defs>
|
||||
<LinearGradient id={`${gradient}-body`} x1="0" y1="0" x2="1" y2="1">
|
||||
<Stop offset="0" stopColor={colors.light} />
|
||||
<Stop offset="0.45" stopColor={colors.base} />
|
||||
<Stop offset="1" stopColor={colors.dark} />
|
||||
</LinearGradient>
|
||||
<RadialGradient id={`${gradient}-shine`} cx="0.3" cy="0.22" r="0.55">
|
||||
<Stop offset="0" stopColor="#FFFFFF" stopOpacity="0.55" />
|
||||
<Stop offset="1" stopColor="#FFFFFF" stopOpacity="0" />
|
||||
</RadialGradient>
|
||||
</Defs>
|
||||
{rig.shapes.map((shape, index) => draw(shape, index, gradient, shown, rig.mouthColor))}
|
||||
</Svg>
|
||||
);
|
||||
}
|
||||
105
apps/mobile/components/routine-event-line.tsx
Normal file
105
apps/mobile/components/routine-event-line.tsx
Normal file
@ -0,0 +1,105 @@
|
||||
import type { RoutineEventBlock } from "@rakazo/contracts";
|
||||
import { isOneShotRoutineCrons } from "@rakazo/core";
|
||||
import { useRouter } from "expo-router";
|
||||
import { useState } from "react";
|
||||
import { Pressable, Text, View } from "react-native";
|
||||
import { rpc } from "../lib/api";
|
||||
import { useI18n } from "../lib/i18n";
|
||||
import { useMobileTokens } from "../lib/native";
|
||||
import { routineLineSchedule } from "../lib/routine-line";
|
||||
import { NativeSymbol } from "./native-symbol";
|
||||
|
||||
/** A routine the bot set up from the conversation: one quiet line with Edit and Undo. */
|
||||
export function RoutineEventLine({ block }: { block: RoutineEventBlock }) {
|
||||
const tokens = useMobileTokens();
|
||||
const { t } = useI18n();
|
||||
const router = useRouter();
|
||||
const [undoing, setUndoing] = useState(false);
|
||||
const [failed, setFailed] = useState(false);
|
||||
const oneShot = isOneShotRoutineCrons(block.crons);
|
||||
const schedule = routineLineSchedule(block);
|
||||
const muted = { color: tokens.mutedForeground, fontSize: 13.5 };
|
||||
|
||||
async function undo() {
|
||||
setUndoing(true);
|
||||
setFailed(false);
|
||||
try {
|
||||
await rpc("routines/remove", { routineId: block.routineId });
|
||||
} catch {
|
||||
setFailed(true);
|
||||
} finally {
|
||||
setUndoing(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<View
|
||||
style={{
|
||||
width: "100%",
|
||||
paddingVertical: 4,
|
||||
flexDirection: "row",
|
||||
flexWrap: "wrap",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
columnGap: 6,
|
||||
rowGap: 2,
|
||||
}}
|
||||
>
|
||||
<View
|
||||
accessible
|
||||
accessibilityRole="image"
|
||||
accessibilityLabel={oneShot ? t("Reminder") : t("Routine")}
|
||||
>
|
||||
<NativeSymbol
|
||||
ios={oneShot ? "bell" : "repeat"}
|
||||
android={oneShot ? "notifications-outline" : "repeat"}
|
||||
size={14}
|
||||
color={tokens.mutedForeground}
|
||||
/>
|
||||
</View>
|
||||
<Text
|
||||
style={[
|
||||
muted,
|
||||
block.removed
|
||||
? { textDecorationLine: "line-through" }
|
||||
: { color: tokens.foreground, fontWeight: "500" },
|
||||
]}
|
||||
>
|
||||
{block.name}
|
||||
</Text>
|
||||
{schedule ? <Text style={muted}>· {schedule}</Text> : null}
|
||||
{block.removed ? (
|
||||
<Text style={muted}>· {t("Removed")}</Text>
|
||||
) : (
|
||||
<>
|
||||
{block.event === "routine.updated" ? <Text style={muted}>· {t("Updated")}</Text> : null}
|
||||
<Text style={muted}>·</Text>
|
||||
<Pressable
|
||||
accessibilityRole="button"
|
||||
hitSlop={10}
|
||||
onPress={() =>
|
||||
router.push({
|
||||
pathname: "/routine",
|
||||
params: { botId: block.botId, routineId: block.routineId },
|
||||
})
|
||||
}
|
||||
>
|
||||
<Text style={[muted, { fontWeight: "600" }]}>{t("Edit")}</Text>
|
||||
</Pressable>
|
||||
<Text style={muted}>·</Text>
|
||||
<Pressable
|
||||
accessibilityRole="button"
|
||||
accessibilityState={{ disabled: undoing }}
|
||||
disabled={undoing}
|
||||
hitSlop={10}
|
||||
onPress={() => void undo()}
|
||||
>
|
||||
<Text style={[muted, { fontWeight: "600", opacity: undoing ? 0.5 : 1 }]}>
|
||||
{failed ? t("Try again") : t("Undo")}
|
||||
</Text>
|
||||
</Pressable>
|
||||
</>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
423
apps/mobile/components/thread-card.tsx
Normal file
423
apps/mobile/components/thread-card.tsx
Normal file
@ -0,0 +1,423 @@
|
||||
import type { CardAct, CardBlock, CardSection } from "@rakazo/contracts";
|
||||
import { CARD_LIMITS } from "@rakazo/contracts";
|
||||
import {
|
||||
applyCardAct,
|
||||
cardScale,
|
||||
cardTimerRemaining,
|
||||
checklistItemDisplay,
|
||||
formatCardClock,
|
||||
formatCardTimerLength,
|
||||
suggestionStatus,
|
||||
} from "@rakazo/core";
|
||||
import { useState } from "react";
|
||||
import { Alert, Pressable, StyleSheet, Text, View } from "react-native";
|
||||
import { mobileTokens } from "../lib/appearance";
|
||||
import { dateLocaleForUi, useI18n } from "../lib/i18n";
|
||||
import { BotAvatar } from "./bot-avatar";
|
||||
import { CookingMode, useNow } from "./cooking-mode";
|
||||
import { NativeSymbol } from "./native-symbol";
|
||||
|
||||
type Section<T extends CardSection["type"]> = Extract<CardSection, { type: T }>;
|
||||
|
||||
/** A bot's interactive card. Taps go to the server, which owns the card's state. */
|
||||
export function ThreadCard({
|
||||
block: stored,
|
||||
createdAt,
|
||||
onAct,
|
||||
}: {
|
||||
block: CardBlock;
|
||||
/** When the card was posted; an open suggestion expires after a while. */
|
||||
createdAt?: string;
|
||||
onAct?: (act: CardAct) => Promise<void>;
|
||||
}) {
|
||||
const tokens = mobileTokens();
|
||||
const { t, locale } = useI18n();
|
||||
const [pending, setPending] = useState<CardAct | null>(null);
|
||||
const [expanded, setExpanded] = useState<string[]>([]);
|
||||
const [cooking, setCooking] = useState(false);
|
||||
// Show the tap in flight on top of the stored card, so the card answers at once.
|
||||
const preview = pending ? applyCardAct(stored, pending) : null;
|
||||
const block = preview?.ok ? preview.block : stored;
|
||||
const suggestion = block.suggestion
|
||||
? suggestionStatus(block, createdAt ?? new Date())
|
||||
: undefined;
|
||||
// A suggestion takes one tap; once it moved on, only its outcome shows.
|
||||
const answered = Boolean(block.answer) || (suggestion !== undefined && suggestion !== "proposed");
|
||||
const busy = !onAct || pending !== null;
|
||||
const factor = cardScale(block);
|
||||
const header = block.sections?.find((part): part is Section<"header"> => part.type === "header");
|
||||
const steps = block.sections?.find((part): part is Section<"steps"> => part.type === "steps");
|
||||
const now = useNow(Boolean(steps?.steps.some((step) => step.timerEndsAt)));
|
||||
|
||||
async function act(next: CardAct) {
|
||||
if (!onAct || pending) return;
|
||||
setPending(next);
|
||||
try {
|
||||
await onAct(next);
|
||||
} catch (error) {
|
||||
Alert.alert(
|
||||
t("Could not submit answer"),
|
||||
error instanceof Error ? error.message : t("Please try again."),
|
||||
);
|
||||
} finally {
|
||||
setPending(null);
|
||||
}
|
||||
}
|
||||
|
||||
function visible<T>(id: string, entries: T[]): { shown: T[]; hidden: number } {
|
||||
if (expanded.includes(id) || entries.length <= CARD_LIMITS.collapsedEntries + 1) {
|
||||
return { shown: entries, hidden: 0 };
|
||||
}
|
||||
return {
|
||||
shown: entries.slice(0, CARD_LIMITS.collapsedEntries),
|
||||
hidden: entries.length - CARD_LIMITS.collapsedEntries,
|
||||
};
|
||||
}
|
||||
|
||||
function showMore(id: string, hidden: number) {
|
||||
if (!hidden) return null;
|
||||
return (
|
||||
<Pressable
|
||||
accessibilityRole="button"
|
||||
onPress={() => setExpanded((ids) => [...ids, id])}
|
||||
style={{ paddingVertical: 6 }}
|
||||
>
|
||||
<Text style={[styles.small, { color: tokens.mutedForeground }]}>
|
||||
{t("Show {count} more", { count: hidden })}
|
||||
</Text>
|
||||
</Pressable>
|
||||
);
|
||||
}
|
||||
|
||||
const roundButton = (
|
||||
label: string,
|
||||
symbol: { ios: string; android: "remove" | "add" },
|
||||
disabled: boolean,
|
||||
onPress: () => void,
|
||||
) => (
|
||||
<Pressable
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={label}
|
||||
accessibilityState={{ disabled }}
|
||||
disabled={disabled}
|
||||
onPress={onPress}
|
||||
style={[styles.round, { borderColor: tokens.border, opacity: disabled ? 0.4 : 1 }]}
|
||||
>
|
||||
<NativeSymbol ios={symbol.ios} android={symbol.android} size={16} />
|
||||
</Pressable>
|
||||
);
|
||||
const select = block.sections?.find((part): part is Section<"select"> => part.type === "select");
|
||||
const nothingChosen = Boolean(select?.multiple && !select.selected?.length);
|
||||
|
||||
return (
|
||||
<View
|
||||
testID="thread-card"
|
||||
style={[styles.card, { borderColor: tokens.border, backgroundColor: tokens.card }]}
|
||||
>
|
||||
{block.sections?.map((part, index) => {
|
||||
if (part.type === "header") {
|
||||
return (
|
||||
<View key={index} style={styles.header}>
|
||||
{part.avatar ? <BotAvatar color={part.avatar} size={56} /> : null}
|
||||
<View style={styles.headerText}>
|
||||
<Text style={[styles.title, { color: tokens.foreground }]}>{part.title}</Text>
|
||||
{part.subtitle ? (
|
||||
<Text style={[styles.small, { color: tokens.mutedForeground }]}>
|
||||
{part.subtitle}
|
||||
</Text>
|
||||
) : null}
|
||||
{part.meta?.length ? (
|
||||
<Text style={[styles.small, { color: tokens.mutedForeground }]}>
|
||||
{part.meta.join(" · ")}
|
||||
</Text>
|
||||
) : null}
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
if (part.type === "facts") {
|
||||
return (
|
||||
<View key={index} style={{ gap: 6 }}>
|
||||
{part.rows.map((row) => (
|
||||
<View key={row.label} style={styles.factRow}>
|
||||
<Text style={[styles.body, { color: tokens.mutedForeground }]}>{row.label}</Text>
|
||||
<Text style={[styles.body, styles.factValue, { color: tokens.foreground }]}>
|
||||
{row.value}
|
||||
</Text>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
if (part.type === "stepper") {
|
||||
const label = part.label;
|
||||
return (
|
||||
<View key={index} style={styles.stepperRow}>
|
||||
<Text style={[styles.body, { color: tokens.mutedForeground }]}>{label}</Text>
|
||||
<View style={styles.stepperControls}>
|
||||
{roundButton(
|
||||
t("Fewer {label}", { label }),
|
||||
{ ios: "minus", android: "remove" },
|
||||
busy || part.value <= 1,
|
||||
() => void act({ type: "stepper", sectionId: part.id, value: part.value - 1 }),
|
||||
)}
|
||||
<Text
|
||||
testID="card-stepper-value"
|
||||
style={[styles.body, styles.stepperValue, { color: tokens.foreground }]}
|
||||
>
|
||||
{part.value}
|
||||
</Text>
|
||||
{roundButton(
|
||||
t("More {label}", { label }),
|
||||
{ ios: "plus", android: "add" },
|
||||
busy || part.value >= CARD_LIMITS.stepperMax,
|
||||
() => void act({ type: "stepper", sectionId: part.id, value: part.value + 1 }),
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
if (part.type === "steps") {
|
||||
const { shown, hidden } = visible(part.id, part.steps);
|
||||
return (
|
||||
<View key={index} style={{ gap: 10 }}>
|
||||
{shown.map((step, position) => {
|
||||
const remaining = cardTimerRemaining(step.timerEndsAt, now);
|
||||
return (
|
||||
<View key={step.id} style={{ flexDirection: "row", gap: 10 }}>
|
||||
<Text
|
||||
style={[styles.body, styles.stepNumber, { color: tokens.mutedForeground }]}
|
||||
>
|
||||
{position + 1}
|
||||
</Text>
|
||||
<View style={{ flex: 1, gap: 2 }}>
|
||||
<Text style={[styles.body, { color: tokens.foreground }]}>{step.text}</Text>
|
||||
{step.timerSeconds ? (
|
||||
<Text style={[styles.small, { color: tokens.mutedForeground }]}>
|
||||
{remaining !== null
|
||||
? formatCardClock(remaining)
|
||||
: formatCardTimerLength(step.timerSeconds, dateLocaleForUi(locale))}
|
||||
</Text>
|
||||
) : null}
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
})}
|
||||
{showMore(part.id, hidden)}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
if (part.type === "checklist") {
|
||||
const { shown, hidden } = visible(part.id, part.items);
|
||||
return (
|
||||
<View key={index}>
|
||||
{shown.map((item) => {
|
||||
const checked = Boolean(item.checked);
|
||||
const display = checklistItemDisplay(item, factor);
|
||||
return (
|
||||
<Pressable
|
||||
key={item.id}
|
||||
accessibilityRole="checkbox"
|
||||
accessibilityState={{ checked, disabled: busy }}
|
||||
disabled={busy}
|
||||
onPress={() =>
|
||||
void act({
|
||||
type: "toggle",
|
||||
sectionId: part.id,
|
||||
itemId: item.id,
|
||||
checked: !checked,
|
||||
})
|
||||
}
|
||||
style={styles.checkRow}
|
||||
>
|
||||
<NativeSymbol
|
||||
ios={checked ? "checkmark.circle.fill" : "circle"}
|
||||
android={checked ? "checkmark-circle" : "ellipse-outline"}
|
||||
size={20}
|
||||
color={checked ? tokens.primary : tokens.mutedForeground}
|
||||
/>
|
||||
<Text
|
||||
style={[
|
||||
styles.body,
|
||||
{
|
||||
flexShrink: 1,
|
||||
color: checked ? tokens.mutedForeground : tokens.foreground,
|
||||
},
|
||||
checked && { textDecorationLine: "line-through" },
|
||||
]}
|
||||
>
|
||||
{display.amount ? (
|
||||
<Text style={{ fontWeight: "600" }}>{display.amount} </Text>
|
||||
) : null}
|
||||
{display.label}
|
||||
</Text>
|
||||
</Pressable>
|
||||
);
|
||||
})}
|
||||
{showMore(part.id, hidden)}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
if (part.type === "select") {
|
||||
return (
|
||||
<View key={index} style={part.multiple ? styles.chips : { gap: 6 }}>
|
||||
{part.options.map((option) => {
|
||||
const chosen = part.selected?.includes(option.id) ?? false;
|
||||
return (
|
||||
<Pressable
|
||||
key={option.id}
|
||||
accessibilityRole="button"
|
||||
accessibilityState={{ selected: chosen, disabled: busy || answered }}
|
||||
disabled={busy || answered}
|
||||
onPress={() =>
|
||||
void act({
|
||||
type: "select",
|
||||
sectionId: part.id,
|
||||
optionId: option.id,
|
||||
...(part.multiple ? { selected: !chosen } : {}),
|
||||
})
|
||||
}
|
||||
style={[
|
||||
styles.option,
|
||||
part.multiple ? null : { alignSelf: "stretch" },
|
||||
{
|
||||
borderColor: chosen ? tokens.primary : tokens.border,
|
||||
backgroundColor: chosen ? tokens.primary : "transparent",
|
||||
opacity: answered && !chosen ? 0.5 : 1,
|
||||
},
|
||||
]}
|
||||
>
|
||||
<Text
|
||||
style={[
|
||||
styles.body,
|
||||
{ color: chosen ? tokens.primaryForeground : tokens.foreground },
|
||||
]}
|
||||
>
|
||||
{option.label}
|
||||
</Text>
|
||||
</Pressable>
|
||||
);
|
||||
})}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
const actions = part.actions.filter((action) => action.kind === "steps" || !answered);
|
||||
if (!actions.length) return null;
|
||||
return (
|
||||
<View key={index} style={styles.chips}>
|
||||
{actions.map((action, position) => {
|
||||
const primary = position === 0;
|
||||
const opensSteps = action.kind === "steps";
|
||||
const disabled = opensSteps ? !steps : busy || nothingChosen;
|
||||
return (
|
||||
<Pressable
|
||||
key={action.id}
|
||||
accessibilityRole="button"
|
||||
accessibilityState={{ disabled }}
|
||||
disabled={disabled}
|
||||
onPress={() =>
|
||||
opensSteps
|
||||
? setCooking(true)
|
||||
: void act({ type: "action", actionId: action.id })
|
||||
}
|
||||
style={[
|
||||
styles.option,
|
||||
{
|
||||
borderColor: primary ? tokens.primary : tokens.border,
|
||||
backgroundColor: primary ? tokens.primary : "transparent",
|
||||
opacity: disabled ? 0.5 : 1,
|
||||
},
|
||||
]}
|
||||
>
|
||||
<Text
|
||||
style={[
|
||||
styles.body,
|
||||
{
|
||||
fontWeight: "600",
|
||||
color: primary ? tokens.primaryForeground : tokens.foreground,
|
||||
},
|
||||
]}
|
||||
>
|
||||
{action.kind === "accept"
|
||||
? t("Accept")
|
||||
: action.kind === "dismiss"
|
||||
? t("Dismiss")
|
||||
: action.label}
|
||||
</Text>
|
||||
</Pressable>
|
||||
);
|
||||
})}
|
||||
</View>
|
||||
);
|
||||
})}
|
||||
{suggestion && suggestion !== "proposed" ? (
|
||||
<Text
|
||||
style={[
|
||||
styles.small,
|
||||
{
|
||||
color:
|
||||
suggestion === "failed"
|
||||
? tokens.destructive
|
||||
: suggestion === "done"
|
||||
? tokens.success
|
||||
: tokens.mutedForeground,
|
||||
},
|
||||
]}
|
||||
>
|
||||
{suggestion === "failed"
|
||||
? (block.suggestion?.error ?? t("Failed"))
|
||||
: suggestion === "running"
|
||||
? t("Running")
|
||||
: suggestion === "done"
|
||||
? t("Done")
|
||||
: suggestion === "dismissed"
|
||||
? t("Dismissed")
|
||||
: t("Expired")}
|
||||
</Text>
|
||||
) : null}
|
||||
{cooking && steps ? (
|
||||
<CookingMode
|
||||
title={header?.title ?? ""}
|
||||
steps={steps}
|
||||
now={now}
|
||||
busy={busy}
|
||||
onAct={act}
|
||||
onClose={() => setCooking(false)}
|
||||
/>
|
||||
) : null}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
card: {
|
||||
width: "90%",
|
||||
gap: 14,
|
||||
borderRadius: 18,
|
||||
borderWidth: 1,
|
||||
paddingHorizontal: 16,
|
||||
paddingVertical: 14,
|
||||
},
|
||||
header: { flexDirection: "row", alignItems: "center", gap: 12 },
|
||||
headerText: { flex: 1, gap: 2 },
|
||||
title: { fontSize: 16, fontWeight: "600" },
|
||||
body: { fontSize: 15.5, lineHeight: 22 },
|
||||
small: { fontSize: 13.5 },
|
||||
factRow: { flexDirection: "row", gap: 12 },
|
||||
factValue: { flex: 1 },
|
||||
checkRow: { flexDirection: "row", alignItems: "center", gap: 10, paddingVertical: 6 },
|
||||
chips: { flexDirection: "row", flexWrap: "wrap", gap: 6 },
|
||||
option: { borderRadius: 12, borderWidth: 1, paddingHorizontal: 14, paddingVertical: 10 },
|
||||
stepperRow: { flexDirection: "row", alignItems: "center", justifyContent: "space-between" },
|
||||
stepperControls: { flexDirection: "row", alignItems: "center", gap: 8 },
|
||||
stepperValue: { minWidth: 28, textAlign: "center", fontWeight: "600" },
|
||||
round: {
|
||||
width: 34,
|
||||
height: 34,
|
||||
borderRadius: 17,
|
||||
borderWidth: 1,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
},
|
||||
stepNumber: { width: 16, textAlign: "right" },
|
||||
});
|
||||
@ -16,6 +16,7 @@ import {
|
||||
MAX_MOBILE_AUTH_RESPONSE_BYTES,
|
||||
MAX_MOBILE_RPC_RESPONSE_BYTES,
|
||||
mergeMobileSnapshot,
|
||||
messageAttachments,
|
||||
passwordResetCapabilities,
|
||||
prependMobileMessagePage,
|
||||
requestPasswordReset,
|
||||
@ -1857,6 +1858,77 @@ describe("mobile thread event reduction", () => {
|
||||
).toBe("Slack · Alex: Hello from the group");
|
||||
});
|
||||
|
||||
it("falls back to plain text and file rows for video, audio and media cards", () => {
|
||||
const blocks = [
|
||||
{
|
||||
kind: "video" as const,
|
||||
artifactId: "vid-1",
|
||||
mimeType: "video/mp4",
|
||||
name: "clip.mp4",
|
||||
size: 10,
|
||||
width: 1080,
|
||||
height: 1920,
|
||||
},
|
||||
{
|
||||
kind: "audio" as const,
|
||||
artifactId: "aud-1",
|
||||
mimeType: "audio/mpeg",
|
||||
name: "memo.mp3",
|
||||
size: 5,
|
||||
},
|
||||
{
|
||||
kind: "media" as const,
|
||||
jobId: "job-1",
|
||||
operation: "export_video" as const,
|
||||
status: "done" as const,
|
||||
title: "Vertical cut",
|
||||
outputs: [
|
||||
{
|
||||
artifactId: "out-1",
|
||||
kind: "video" as const,
|
||||
mimeType: "video/mp4",
|
||||
name: "cut.mp4",
|
||||
size: 7,
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
expect(blockText(mobileMessage("media-1", blocks))).toBe(
|
||||
"[video: clip.mp4]\n[audio: memo.mp3]\nVertical cut: done",
|
||||
);
|
||||
expect(messageAttachments(blocks).map((item) => [item.kind, item.artifactId])).toEqual([
|
||||
["video", "vid-1"],
|
||||
["audio", "aud-1"],
|
||||
["video", "out-1"],
|
||||
]);
|
||||
});
|
||||
|
||||
it("reads a choice card as its question and answer", () => {
|
||||
const card = {
|
||||
kind: "choice" as const,
|
||||
step: "goal" as const,
|
||||
question: "What’s one thing you want to get done in the next few weeks?",
|
||||
textInput: { placeholder: "Your goal" },
|
||||
options: [{ id: "skip", letter: "", label: "Skip" }],
|
||||
};
|
||||
expect(blockText(mobileMessage("choice-1", [card]))).toBe(card.question);
|
||||
expect(
|
||||
blockText(
|
||||
mobileMessage("choice-2", [{ ...card, answerId: "_text", answerText: "Run a 10K" }]),
|
||||
),
|
||||
).toBe(`${card.question}\nRun a 10K`);
|
||||
expect(blockText(mobileMessage("choice-3", [{ ...card, answerId: "skip" }]))).toBe(
|
||||
`${card.question}\nSkip`,
|
||||
);
|
||||
const note = {
|
||||
kind: "intake_note" as const,
|
||||
note: "done" as const,
|
||||
nextCheckInAt: null,
|
||||
text: "All set. I’m here whenever you need me.",
|
||||
};
|
||||
expect(blockText(mobileMessage("note-1", [note]))).toBe(note.text);
|
||||
});
|
||||
|
||||
it("deduplicates durable messages and replaces matching transient subagent state", () => {
|
||||
const initial = snapshot([
|
||||
mobileMessage("message-1", [{ kind: "text", text: "old" }]),
|
||||
|
||||
@ -728,7 +728,7 @@ export type MobileBot = Pick<
|
||||
| "modelId"
|
||||
| "thinkingLevel"
|
||||
> &
|
||||
Partial<Pick<Bot, "parentBotId" | "spaceId">>;
|
||||
Partial<Pick<Bot, "parentBotId" | "spaceId" | "kind">>;
|
||||
|
||||
export type MobileBotSection = BotSection;
|
||||
|
||||
@ -790,9 +790,11 @@ export type MobileSnapshot = {
|
||||
activeRuns?: Array<{ id: string; botId?: string; status: string }>;
|
||||
members?: MobileGroup["members"];
|
||||
computer?: {
|
||||
kind?: string;
|
||||
state: string;
|
||||
controlHolder: string;
|
||||
screenAvailable: boolean;
|
||||
takeoverRequested?: boolean;
|
||||
mode: ComputerMode;
|
||||
busyBotName: string | null;
|
||||
};
|
||||
@ -860,6 +862,24 @@ export function copyableMobileMessageText(message: MobileMessage): string {
|
||||
.trim();
|
||||
}
|
||||
|
||||
export type MobileAttachment = {
|
||||
kind: "image" | "video" | "audio" | "file";
|
||||
artifactId: string;
|
||||
name: string;
|
||||
mimeType: string;
|
||||
size?: number;
|
||||
};
|
||||
|
||||
/** Files a message carries, including the outputs of a finished media card, as tappable rows. */
|
||||
export function messageAttachments(blocks: readonly MessageBlock[]): MobileAttachment[] {
|
||||
return blocks.flatMap((block): MobileAttachment[] => {
|
||||
if (block.kind === "image") return [{ ...block, kind: "image" }];
|
||||
if (block.kind === "video" || block.kind === "audio" || block.kind === "file") return [block];
|
||||
if (block.kind === "media") return block.outputs;
|
||||
return [];
|
||||
});
|
||||
}
|
||||
|
||||
export function blockText(message: MobileMessage) {
|
||||
return message.blocks
|
||||
.map((block) => {
|
||||
@ -874,11 +894,29 @@ export function blockText(message: MobileMessage) {
|
||||
if (block.kind === "child_bot") {
|
||||
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 === "event") return block.name;
|
||||
if (block.kind === "autopilot") return block.text ?? block.goal;
|
||||
if (block.kind === "card") {
|
||||
const header = block.sections?.find((part) => part.type === "header");
|
||||
return header?.type === "header"
|
||||
? header.title
|
||||
: (block.lines ?? []).map((line) => `${line.k}: ${line.v}`).join("\n");
|
||||
}
|
||||
if (block.kind === "image") return `[image: ${block.name ?? "attachment"}]`;
|
||||
if (block.kind === "file") {
|
||||
return `[file: ${block.name ?? "attachment"}${block.size ? ` (${block.size} bytes)` : ""}]`;
|
||||
}
|
||||
if (block.kind === "video" || block.kind === "audio") {
|
||||
return `[${block.kind}: ${block.name ?? "attachment"}]`;
|
||||
}
|
||||
if (block.kind === "media") return `${block.title}: ${block.status}`;
|
||||
if (block.kind === "steps") {
|
||||
return (block.steps ?? [])
|
||||
.map((step) => `${step.label}${step.count > 1 ? ` ×${step.count}` : ""}`)
|
||||
@ -896,6 +934,7 @@ type ThreadEvent = {
|
||||
type: string;
|
||||
seq?: number;
|
||||
runId?: string;
|
||||
createdAt?: string;
|
||||
payload?: Record<string, unknown>;
|
||||
};
|
||||
|
||||
@ -1074,7 +1113,7 @@ export function applyMobileThreadEvent(
|
||||
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 };
|
||||
}
|
||||
if (event.type === "thread.subagent") {
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
import { attachmentKindOf } from "@rakazo/contracts";
|
||||
import { File, Paths } from "expo-file-system";
|
||||
import * as Sharing from "expo-sharing";
|
||||
import { rpc } from "./api";
|
||||
import { captureApiRequestContext, rpc } from "./api";
|
||||
import { artifactCacheFileName } from "./artifact-file";
|
||||
import { t } from "./i18n";
|
||||
|
||||
@ -11,6 +12,16 @@ async function cacheMobileArtifact(
|
||||
artifactId: string,
|
||||
mimeType: string,
|
||||
): Promise<File> {
|
||||
const kind = attachmentKindOf(mimeType);
|
||||
if (kind === "video" || kind === "audio") {
|
||||
// Media can be hundreds of megabytes: stream it to disk instead of a base64 RPC payload.
|
||||
const { apiBase, headers } = await captureApiRequestContext();
|
||||
return File.downloadFileAsync(
|
||||
`${apiBase}/api/artifacts/${encodeURIComponent(artifactId)}`,
|
||||
new File(Paths.cache, artifactCacheFileName(artifactId, mimeType)),
|
||||
{ headers, idempotent: true },
|
||||
);
|
||||
}
|
||||
const artifact = await rpc<{ contentBase64: string }>("artifacts/get", {
|
||||
...target,
|
||||
artifactId,
|
||||
|
||||
50
apps/mobile/lib/autopilot.test.ts
Normal file
50
apps/mobile/lib/autopilot.test.ts
Normal file
@ -0,0 +1,50 @@
|
||||
import type { AutopilotRecent } from "@rakazo/contracts";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { autopilotSummary, autopilotTimeOfDay } from "./autopilot";
|
||||
|
||||
vi.mock("./i18n", () => ({
|
||||
t: (message: string, values?: Record<string, string | number>) =>
|
||||
message.replace(/\{(\w+)\}/g, (_, key: string) => String(values?.[key] ?? "")),
|
||||
}));
|
||||
|
||||
function autopilot(patch: Partial<AutopilotRecent> = {}): AutopilotRecent {
|
||||
return {
|
||||
id: "autopilot-1",
|
||||
botId: "bot-1",
|
||||
threadId: "thread-1",
|
||||
spaceId: "space-1",
|
||||
groupId: null,
|
||||
goal: "Tidy the tickets",
|
||||
focus: null,
|
||||
until: null,
|
||||
status: "active",
|
||||
pauseReason: null,
|
||||
lastCheckpoint: null,
|
||||
nextStep: null,
|
||||
result: null,
|
||||
stepCount: 0,
|
||||
startedAt: "2026-10-05T00:00:00.000Z",
|
||||
endedAt: null,
|
||||
...patch,
|
||||
};
|
||||
}
|
||||
|
||||
describe("autopilot on mobile", () => {
|
||||
it("hands the picked time to autopilotUntil as HH:MM", () => {
|
||||
expect(autopilotTimeOfDay(new Date(2026, 9, 5, 6, 5))).toBe("06:05");
|
||||
expect(autopilotTimeOfDay(new Date(2026, 9, 5, 23, 30))).toBe("23:30");
|
||||
});
|
||||
|
||||
it("sums up the night like Today on web", () => {
|
||||
expect(
|
||||
autopilotSummary(autopilot({ stepCount: 2, lastCheckpoint: "Ticket 2 tidied" })),
|
||||
).toEqual({ detail: "last: Ticket 2 tidied", trail: "2 steps" });
|
||||
expect(
|
||||
autopilotSummary(autopilot({ status: "finished", stepCount: 1, result: "All tidy." })),
|
||||
).toEqual({ detail: "All tidy.", trail: "1 step" });
|
||||
expect(
|
||||
autopilotSummary(autopilot({ status: "paused", stepCount: 3, pauseReason: "Need a key." })),
|
||||
).toEqual({ detail: "Need a key.", trail: "Paused" });
|
||||
expect(autopilotSummary(autopilot())).toEqual({ detail: "", trail: "0 steps" });
|
||||
});
|
||||
});
|
||||
30
apps/mobile/lib/autopilot.ts
Normal file
30
apps/mobile/lib/autopilot.ts
Normal file
@ -0,0 +1,30 @@
|
||||
import type { AutopilotRecent } from "@rakazo/contracts";
|
||||
import { t } from "./i18n";
|
||||
|
||||
/** "HH:MM" for autopilotUntil's time choice. */
|
||||
export function autopilotTimeOfDay(date: Date): string {
|
||||
const pad = (value: number) => String(value).padStart(2, "0");
|
||||
return `${pad(date.getHours())}:${pad(date.getMinutes())}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* One autopilot in the home list, like Today on web: how it ended or where it stands, and its step
|
||||
* count, or Paused when it waits on the user.
|
||||
*/
|
||||
export function autopilotSummary(autopilot: AutopilotRecent): { detail: string; trail: string } {
|
||||
const detail =
|
||||
autopilot.status === "finished"
|
||||
? autopilot.result
|
||||
: autopilot.status === "paused"
|
||||
? autopilot.pauseReason
|
||||
: autopilot.lastCheckpoint
|
||||
? t("last: {latest}", { latest: autopilot.lastCheckpoint })
|
||||
: null;
|
||||
const trail =
|
||||
autopilot.status === "paused"
|
||||
? t("Paused")
|
||||
: autopilot.stepCount === 1
|
||||
? t("1 step")
|
||||
: t("{count} steps", { count: autopilot.stepCount });
|
||||
return { detail: detail ?? "", trail };
|
||||
}
|
||||
@ -1,16 +0,0 @@
|
||||
import { workingAvatarDuration, workingAvatarFrame } from "@rakazo/core";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
describe("working avatar motion", () => {
|
||||
it("reuses shared core choreography", () => {
|
||||
const start = workingAvatarFrame(0, 0);
|
||||
const end = workingAvatarFrame(0, 1);
|
||||
expect(end.translationY).toBeCloseTo(start.translationY);
|
||||
expect(end.scaleX).toBeCloseTo(start.scaleX);
|
||||
expect(end.eyeOffsetX).toBeCloseTo(start.eyeOffsetX);
|
||||
expect(end.eyeOffsetY).toBeCloseTo(start.eyeOffsetY);
|
||||
expect(workingAvatarFrame(0, 0.5)).not.toEqual(workingAvatarFrame(0, 0));
|
||||
expect(workingAvatarFrame(2, 0.5)).not.toEqual(workingAvatarFrame(0, 0.5));
|
||||
expect(workingAvatarDuration(6)).toBe(1100);
|
||||
});
|
||||
});
|
||||
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