Compare commits
131 Commits
claude/pha
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 62a4414968 | |||
| f2841bdd74 | |||
| 95d786ef2c | |||
|
|
f11be8735c | ||
|
|
679b90a667 | ||
|
|
ea7892baa3 | ||
|
|
467d9b0dc4 | ||
|
|
5168518759 | ||
|
|
8a98bfe79f | ||
|
|
de95cf1b91 | ||
|
|
9a753e9adb | ||
|
|
95061c648f | ||
|
|
78827e0a5b | ||
|
|
fe80edbf52 | ||
|
|
996af168ef | ||
|
|
35b7291660 | ||
|
|
75423579b7 | ||
|
|
ac0384e49c | ||
|
|
000745592a | ||
|
|
d1dd8ffcd9 | ||
|
|
263e3a31f8 | ||
|
|
bb7cf3f55e | ||
|
|
66dbf0046e | ||
|
|
ac9fcce0fb | ||
|
|
f820c75632 | ||
|
|
be657f6e3c | ||
|
|
f6053dd2a8 | ||
|
|
1611fe5fa2 | ||
| cd770b9a1d | |||
|
|
a27cdf733c | ||
|
|
a0bdf6ddc8 | ||
|
|
9bc3187a4a | ||
|
|
7452b8c6d2 | ||
|
|
f136451e22 | ||
|
|
d962e5e7f4 | ||
|
|
a36b45b791 | ||
|
|
ff7e95a081 | ||
|
|
0b3ce11df8 | ||
|
|
ca869561ac | ||
|
|
2073b50c94 | ||
|
|
20421e6d58 | ||
|
|
557f8486cd | ||
|
|
f14b112cdd | ||
|
|
22fa2e1606 | ||
|
|
eb777d63ee | ||
|
|
d6835fe0b0 | ||
|
|
42eb420545 | ||
|
|
f3b47cb7cd | ||
|
|
4cd9468b6f | ||
|
|
4585d3b0ec | ||
|
|
3469165c92 | ||
|
|
797906dde2 | ||
|
|
81c4bfd7e3 | ||
|
|
a746783c48 | ||
|
|
634b3f823b | ||
|
|
6ace14c3c8 | ||
|
|
1c8d13a837 | ||
|
|
5cf9d679b4 | ||
|
|
dd177f7ef5 | ||
|
|
83d2106633 | ||
|
|
f720683496 | ||
|
|
4b814c9a17 | ||
|
|
45d3f1ae11 | ||
|
|
8ae3764b05 | ||
|
|
6c0fba8cc1 | ||
|
|
b1b76a846e | ||
|
|
f07d17e927 | ||
|
|
81468063ff | ||
|
|
b8781a0081 | ||
|
|
a2c7182ed6 | ||
|
|
e4b995ec14 | ||
|
|
97787a0272 | ||
|
|
0e1036b2ff | ||
|
|
ba89574c83 | ||
|
|
f8b4ce4b6d | ||
|
|
50e56a2658 | ||
|
|
629fcf6b41 | ||
|
|
28cdb33e57 | ||
|
|
58823d7187 | ||
|
|
5a406b16c3 | ||
|
|
de26578778 | ||
|
|
f8a5ff8e43 | ||
|
|
c50f003744 | ||
|
|
eeec01cd0e | ||
|
|
6fc734b0ae | ||
|
|
a55ed44394 | ||
|
|
b78eff1f7b | ||
|
|
9062717eee | ||
|
|
eb9c8847a4 | ||
|
|
459d7c99e4 | ||
|
|
b2fb517286 | ||
|
|
0122fef89e | ||
|
|
3b2c46dd36 | ||
|
|
3291a32d37 | ||
|
|
23d70971f8 | ||
|
|
e55207f5d2 | ||
|
|
568d37c48c | ||
|
|
a6e611736b | ||
|
|
4ab0d25224 | ||
|
|
c98eb6e843 | ||
|
|
738811dbe7 | ||
|
|
f3e438444a | ||
|
|
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 |
@ -99,8 +99,12 @@ PI_DEFAULT_PROVIDER=openrouter
|
||||
# Computer-use tests can override this with COMPUTER_E2E_MODEL.
|
||||
PI_DEFAULT_MODEL=openai/gpt-5.6-luna
|
||||
# Optional local OpenAI-compatible models. Leave blank to disable the provider.
|
||||
# With PI_DEFAULT_PROVIDER=local they are the deployment's model and nobody has to connect one.
|
||||
# An entry may name its own server: id=http://host:port/v1.
|
||||
RAKAZO_LOCAL_MODELS=
|
||||
RAKAZO_LOCAL_MODELS_URL=http://127.0.0.1:11434/v1
|
||||
# Optional tiers the agent can delegate to, for every space: fast=id,standard=id,code=id.
|
||||
RAKAZO_LOCAL_TIERS=
|
||||
RAKAZO_LOCAL_CONTEXT_WINDOW=32768
|
||||
RAKAZO_LOCAL_MAX_TOKENS=4096
|
||||
# Allow user-connected OpenAI-compatible endpoints on public hostnames (default: private/loopback only).
|
||||
@ -202,10 +206,10 @@ RAKAZO_COMPOSE_FILE=
|
||||
# RAKAZO_UPDATE_SERVICES=supervisor
|
||||
RAKAZO_UPDATE_SERVICES=
|
||||
# Published images (defaults match this repository). Tag `local` is built from the checkout.
|
||||
RAKAZO_IMAGE=ghcr.io/elie222/rakazo/app
|
||||
RAKAZO_IMAGE=ghcr.io/cynarai/evoo-local/app
|
||||
RAKAZO_IMAGE_TAG=local
|
||||
RAKAZO_IMAGE_TAG_PREVIOUS=
|
||||
RAKAZO_UPDATER_IMAGE=ghcr.io/elie222/rakazo/updater
|
||||
RAKAZO_UPDATER_IMAGE=ghcr.io/cynarai/evoo-local/updater
|
||||
RAKAZO_UPDATER_IMAGE_TAG=local
|
||||
# Required only when starting the opt-in `updater` Compose profile; the sidecar refuses to start
|
||||
# without it. Use a dedicated random value, not BETTER_AUTH_SECRET,
|
||||
|
||||
6
.github/ISSUE_TEMPLATE/bug.yml
vendored
6
.github/ISSUE_TEMPLATE/bug.yml
vendored
@ -1,5 +1,5 @@
|
||||
name: Bug report
|
||||
description: Something is broken in Rakazo
|
||||
description: Something is broken in evOo
|
||||
title: "[Bug]: "
|
||||
labels:
|
||||
- bug
|
||||
@ -7,7 +7,7 @@ body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
For **security vulnerabilities**, email [security@rakazo.com](mailto:security@rakazo.com) — do not use this form. See [SECURITY.md](https://github.com/elie222/rakazo/blob/main/SECURITY.md).
|
||||
For **security vulnerabilities**, open a [private security advisory](https://github.com/cynarAI/evOo-local/security/advisories/new) do not use this form. See [SECURITY.md](https://github.com/cynarAI/evOo-local/blob/main/SECURITY.md).
|
||||
|
||||
- type: checkboxes
|
||||
id: not-security
|
||||
@ -55,7 +55,7 @@ body:
|
||||
- type: input
|
||||
id: version
|
||||
attributes:
|
||||
label: Rakazo version or commit
|
||||
label: evOo version or commit
|
||||
placeholder: v0.1.0 or abc1234
|
||||
validations:
|
||||
required: true
|
||||
|
||||
10
.github/ISSUE_TEMPLATE/config.yml
vendored
10
.github/ISSUE_TEMPLATE/config.yml
vendored
@ -1,11 +1,5 @@
|
||||
blank_issues_enabled: false
|
||||
contact_links:
|
||||
- name: Security vulnerabilities
|
||||
url: https://github.com/elie222/rakazo/blob/main/SECURITY.md
|
||||
about: Report security issues to security@rakazo.com — do not open a public issue.
|
||||
- name: User support
|
||||
url: mailto:support@rakazo.com
|
||||
about: Questions about using Rakazo or self-hosting help.
|
||||
- name: Maintainer
|
||||
url: mailto:elie@rakazo.com
|
||||
about: Reach the maintainer directly.
|
||||
url: https://github.com/cynarAI/evOo-local/security/advisories/new
|
||||
about: Report security issues privately. Do not open a public issue.
|
||||
|
||||
8
.github/ISSUE_TEMPLATE/self-host.yml
vendored
8
.github/ISSUE_TEMPLATE/self-host.yml
vendored
@ -1,5 +1,5 @@
|
||||
name: Self-host setup
|
||||
description: Cannot start Rakazo, run compose, migrate, or build the sandbox
|
||||
description: Cannot start evOo, run compose, migrate, or build the sandbox
|
||||
title: "[Self-host]: "
|
||||
labels:
|
||||
- self-host
|
||||
@ -7,9 +7,9 @@ body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
For **security vulnerabilities**, email [security@rakazo.com](mailto:security@rakazo.com). See [SECURITY.md](https://github.com/elie222/rakazo/blob/main/SECURITY.md).
|
||||
For **security vulnerabilities**, open a [private security advisory](https://github.com/cynarAI/evOo-local/security/advisories/new). See [SECURITY.md](https://github.com/cynarAI/evOo-local/blob/main/SECURITY.md).
|
||||
|
||||
Setup steps are in [README.md](https://github.com/elie222/rakazo/blob/main/README.md). Do not paste `.env` contents or API keys.
|
||||
Setup steps are in [README.md](https://github.com/cynarAI/evOo-local/blob/main/README.md). Do not paste `.env` contents or API keys.
|
||||
|
||||
- type: dropdown
|
||||
id: step
|
||||
@ -46,7 +46,7 @@ body:
|
||||
- type: input
|
||||
id: version
|
||||
attributes:
|
||||
label: Rakazo version or commit
|
||||
label: evOo version or commit
|
||||
placeholder: main @ abc1234
|
||||
validations:
|
||||
required: true
|
||||
|
||||
2
.github/MERGE_GATES.md
vendored
2
.github/MERGE_GATES.md
vendored
@ -24,7 +24,7 @@ These workflows may fail without blocking product delivery. Do **not** add them
|
||||
| Check / workflow | Why infra |
|
||||
| --- | --- |
|
||||
| `macOS setup screenshot (infra)` (`desktop-macos-screenshot.yml`) | Native screenshot capture; skips when the runner has no display |
|
||||
| Playwright / desktop artifact uploads | Soft-fail (`continue-on-error`) when GitHub artifact quota is exhausted |
|
||||
| Playwright report handoff | Soft-fail (`continue-on-error`); the report stays on the evo runner host for `publish Playwright report` |
|
||||
| `publish-server-image.yml` (PR paths) | Docker image validation for infra changes |
|
||||
| `mobile Android screenshots` | Manual `workflow_dispatch` only |
|
||||
| `nightly verification` | Scheduled visual regression |
|
||||
|
||||
5
.github/workflows/autopilot-auto-merge.yml
vendored
5
.github/workflows/autopilot-auto-merge.yml
vendored
@ -71,7 +71,10 @@ jobs:
|
||||
HEAD_SHA: ${{ steps.pr.outputs.head_sha }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
mapfile -t conclusions < <(
|
||||
conclusions=()
|
||||
while IFS= read -r conclusion; do
|
||||
[[ -n "$conclusion" ]] && conclusions+=("$conclusion")
|
||||
done < <(
|
||||
gh api "repos/${GITHUB_REPOSITORY}/commits/${HEAD_SHA}/check-runs" --paginate \
|
||||
--jq '.check_runs[] | select(.name == "Product CI") | .conclusion'
|
||||
)
|
||||
|
||||
171
.github/workflows/ci.yml
vendored
171
.github/workflows/ci.yml
vendored
@ -5,19 +5,87 @@ name: ci
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
paths:
|
||||
- "apps/**"
|
||||
- "packages/**"
|
||||
- "infra/**"
|
||||
- "scripts/**"
|
||||
- "ops/**"
|
||||
- ".github/**"
|
||||
- "package.json"
|
||||
- "pnpm-lock.yaml"
|
||||
- "pnpm-workspace.yaml"
|
||||
- "turbo.json"
|
||||
- "biome.json"
|
||||
- "vitest.config.ts"
|
||||
- "tsconfig*.json"
|
||||
pull_request:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: read
|
||||
|
||||
concurrency:
|
||||
group: ci-${{ github.event.pull_request.number || github.ref }}
|
||||
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
|
||||
|
||||
# Jobs that only need Node/pnpm/bash use [self-hosted, evoo-ci] so macOS runners (air, mini)
|
||||
# can share load. Linux-only work keeps the Linux label (Docker, xvfb, local Postgres :5433,
|
||||
# Homebrew ffmpeg with libass/subtitles filter for media unit tests).
|
||||
|
||||
jobs:
|
||||
changes:
|
||||
name: Detect product changes
|
||||
runs-on: [self-hosted, evoo-ci]
|
||||
timeout-minutes: 10
|
||||
outputs:
|
||||
product: ${{ steps.product.outputs.product }}
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
with:
|
||||
persist-credentials: false
|
||||
fetch-depth: 0
|
||||
- uses: dorny/paths-filter@de90cc6fb38fc0963ad72b210f1f284cd68cea36 # v3.0.2
|
||||
id: filter
|
||||
continue-on-error: true
|
||||
with:
|
||||
filters: |
|
||||
product:
|
||||
- 'apps/**'
|
||||
- 'packages/**'
|
||||
- 'infra/**'
|
||||
- 'scripts/**'
|
||||
- 'ops/**'
|
||||
- '.github/**'
|
||||
- 'package.json'
|
||||
- 'pnpm-lock.yaml'
|
||||
- 'pnpm-workspace.yaml'
|
||||
- 'turbo.json'
|
||||
- 'biome.json'
|
||||
- 'vitest.config.ts'
|
||||
- 'tsconfig*.json'
|
||||
- name: Product path decision
|
||||
id: product
|
||||
if: always()
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [[ "${{ steps.filter.outcome }}" == "success" ]]; then
|
||||
if [[ "${{ steps.filter.outputs.product }}" == "true" ]]; then
|
||||
echo "product=true" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "product=false" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
else
|
||||
echo "Change detection failed; running full Product CI." >&2
|
||||
echo "product=true" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
lint:
|
||||
name: Lint
|
||||
runs-on: [self-hosted, evoo-ci, Linux]
|
||||
needs: changes
|
||||
if: ${{ (needs.changes.result == 'success' && needs.changes.outputs.product == 'true') || needs.changes.result != 'success' }}
|
||||
runs-on: [self-hosted, evoo-ci]
|
||||
timeout-minutes: 20
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
with:
|
||||
@ -45,13 +113,15 @@ jobs:
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
cache: pnpm
|
||||
- run: pnpm install --frozen-lockfile
|
||||
- run: pnpm lint
|
||||
|
||||
check:
|
||||
name: Typecheck
|
||||
runs-on: [self-hosted, evoo-ci, Linux]
|
||||
needs: changes
|
||||
if: ${{ (needs.changes.result == 'success' && needs.changes.outputs.product == 'true') || needs.changes.result != 'success' }}
|
||||
runs-on: [self-hosted, evoo-ci]
|
||||
timeout-minutes: 20
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
with:
|
||||
@ -79,7 +149,6 @@ jobs:
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
cache: pnpm
|
||||
- run: pnpm install --frozen-lockfile
|
||||
- run: pnpm db:generate
|
||||
env:
|
||||
@ -88,6 +157,8 @@ jobs:
|
||||
|
||||
build:
|
||||
name: Production builds
|
||||
needs: changes
|
||||
if: ${{ (needs.changes.result == 'success' && needs.changes.outputs.product == 'true') || needs.changes.result != 'success' }}
|
||||
runs-on: [self-hosted, evoo-ci, Linux]
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
@ -117,7 +188,6 @@ jobs:
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
cache: pnpm
|
||||
- run: pnpm install --frozen-lockfile
|
||||
- run: pnpm build
|
||||
env:
|
||||
@ -126,18 +196,13 @@ jobs:
|
||||
ENCRYPTION_KEY: ci-build-encryption-key-at-least-32-characters
|
||||
- name: Electron smoke
|
||||
run: xvfb-run --auto-servernum pnpm --filter @rakazo/desktop exec playwright test --config e2e/playwright.config.ts
|
||||
- name: Upload desktop setup screenshots
|
||||
if: always()
|
||||
continue-on-error: true
|
||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
|
||||
with:
|
||||
name: desktop-e2e-screenshots
|
||||
path: apps/desktop/e2e/screenshots
|
||||
if-no-files-found: ignore
|
||||
|
||||
test:
|
||||
name: Unit tests
|
||||
needs: changes
|
||||
if: ${{ (needs.changes.result == 'success' && needs.changes.outputs.product == 'true') || needs.changes.result != 'success' }}
|
||||
runs-on: [self-hosted, evoo-ci, Linux]
|
||||
timeout-minutes: 45
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
with:
|
||||
@ -165,7 +230,6 @@ jobs:
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
cache: pnpm
|
||||
- run: pnpm install --frozen-lockfile
|
||||
- run: pnpm db:generate
|
||||
env:
|
||||
@ -174,6 +238,8 @@ jobs:
|
||||
|
||||
test-integration:
|
||||
name: Postgres journeys
|
||||
needs: changes
|
||||
if: ${{ (needs.changes.result == 'success' && needs.changes.outputs.product == 'true') || needs.changes.result != 'success' }}
|
||||
runs-on: [self-hosted, evoo-ci, Linux]
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
@ -203,24 +269,28 @@ jobs:
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
cache: pnpm
|
||||
- run: pnpm install --frozen-lockfile
|
||||
- run: pnpm test:integration
|
||||
|
||||
test-e2e:
|
||||
name: Web E2E
|
||||
needs: changes
|
||||
if: ${{ (needs.changes.result == 'success' && needs.changes.outputs.product == 'true') || needs.changes.result != 'success' }}
|
||||
uses: ./.github/workflows/playwright.yml
|
||||
with:
|
||||
upload_artifacts: true
|
||||
hand_off_report: true
|
||||
|
||||
product-gate:
|
||||
name: Product CI
|
||||
if: always()
|
||||
needs: [lint, check, build, test, test-integration, test-e2e]
|
||||
runs-on: [self-hosted, evoo-ci, Linux]
|
||||
needs: [changes, lint, check, build, test, test-integration, test-e2e]
|
||||
runs-on: [self-hosted, evoo-ci]
|
||||
timeout-minutes: 5
|
||||
steps:
|
||||
- name: Verify product checks
|
||||
env:
|
||||
CHANGES: ${{ needs.changes.result }}
|
||||
PRODUCT: ${{ needs.changes.outputs.product }}
|
||||
LINT: ${{ needs.lint.result }}
|
||||
CHECK: ${{ needs.check.result }}
|
||||
BUILD: ${{ needs.build.result }}
|
||||
@ -229,11 +299,23 @@ jobs:
|
||||
TEST_E2E: ${{ needs.test-e2e.result }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
product="$PRODUCT"
|
||||
if [[ "$CHANGES" != "success" ]]; then
|
||||
echo "Change detection did not finish cleanly; requiring downstream product checks." >&2
|
||||
product=true
|
||||
fi
|
||||
if [[ "$product" != "true" ]]; then
|
||||
echo "No product-impacting paths changed; Product CI satisfied."
|
||||
exit 0
|
||||
fi
|
||||
failed=0
|
||||
for name in LINT CHECK BUILD TEST TEST_INTEGRATION TEST_E2E; do
|
||||
result="${!name}"
|
||||
printf '%s=%s\n' "$name" "$result"
|
||||
if [[ "$result" != "success" ]]; then
|
||||
if [[ "$result" == "skipped" ]]; then
|
||||
echo "$name was skipped although product paths changed." >&2
|
||||
failed=1
|
||||
elif [[ "$result" != "success" ]]; then
|
||||
failed=1
|
||||
fi
|
||||
done
|
||||
@ -245,9 +327,9 @@ jobs:
|
||||
|
||||
publish-mobile-update:
|
||||
name: Publish compatible mobile update
|
||||
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
|
||||
if: github.event_name == 'push' && github.ref == 'refs/heads/main' && needs.changes.outputs.product == 'true'
|
||||
continue-on-error: true
|
||||
needs: [lint, check, build, test, test-integration, test-e2e]
|
||||
needs: [changes, lint, check, build, test, test-integration, test-e2e]
|
||||
runs-on: [self-hosted, evoo-ci, Linux]
|
||||
timeout-minutes: 20
|
||||
concurrency:
|
||||
@ -388,7 +470,6 @@ jobs:
|
||||
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' && steps.expo_token.outputs.ready == 'true'
|
||||
uses: expo/expo-github-action@eab7a230208c952974db8c3245cfd78402c7b385 # v9
|
||||
@ -396,6 +477,9 @@ jobs:
|
||||
eas-version: latest
|
||||
packager: pnpm
|
||||
token: ${{ secrets.EXPO_TOKEN }}
|
||||
# Its default caches go to the GitHub cache service; keep them on the runner.
|
||||
eas-cache: false
|
||||
expo-cache: false
|
||||
- name: Install dependencies
|
||||
if: steps.ota.outputs.publish == 'true' && steps.expo_token.outputs.ready == 'true'
|
||||
run: pnpm install --frozen-lockfile
|
||||
@ -422,46 +506,3 @@ jobs:
|
||||
fi
|
||||
eas update --platform all --channel production --environment production --message "$GITHUB_SHA" --non-interactive
|
||||
|
||||
deploy-production:
|
||||
if: >-
|
||||
github.event_name == 'push' &&
|
||||
github.ref == 'refs/heads/main' &&
|
||||
vars.PRODUCTION_DEPLOY_ENABLED == 'true'
|
||||
needs: [lint, check, build, test, test-integration, test-e2e]
|
||||
runs-on: [self-hosted, evoo-ci, Linux]
|
||||
timeout-minutes: 30
|
||||
concurrency:
|
||||
group: production
|
||||
cancel-in-progress: false
|
||||
environment:
|
||||
name: production
|
||||
url: https://app.rakazo.com
|
||||
steps:
|
||||
- name: Configure production SSH identity
|
||||
env:
|
||||
SSH_PRIVATE_KEY: ${{ secrets.PRODUCTION_SSH_PRIVATE_KEY }}
|
||||
SSH_KNOWN_HOSTS: ${{ secrets.PRODUCTION_SSH_KNOWN_HOSTS }}
|
||||
run: |
|
||||
test -n "$SSH_PRIVATE_KEY"
|
||||
test -n "$SSH_KNOWN_HOSTS"
|
||||
install -d -m 700 "$HOME/.ssh"
|
||||
printf '%s\n' "$SSH_PRIVATE_KEY" > "$HOME/.ssh/rakazo-production"
|
||||
printf '%s\n' "$SSH_KNOWN_HOSTS" > "$HOME/.ssh/known_hosts"
|
||||
chmod 600 "$HOME/.ssh/rakazo-production" "$HOME/.ssh/known_hosts"
|
||||
ssh-keygen -y -f "$HOME/.ssh/rakazo-production" >/dev/null
|
||||
|
||||
- name: Deploy successful main revision
|
||||
env:
|
||||
SSH_HOST: ${{ secrets.PRODUCTION_SSH_HOST }}
|
||||
SSH_USER: ${{ secrets.PRODUCTION_SSH_USER }}
|
||||
run: |
|
||||
test -n "$SSH_HOST"
|
||||
test -n "$SSH_USER"
|
||||
ssh -F /dev/null \
|
||||
-i "$HOME/.ssh/rakazo-production" \
|
||||
-o BatchMode=yes \
|
||||
-o ConnectTimeout=15 \
|
||||
-o IdentitiesOnly=yes \
|
||||
-o StrictHostKeyChecking=yes \
|
||||
-o UserKnownHostsFile="$HOME/.ssh/known_hosts" \
|
||||
"$SSH_USER@$SSH_HOST" deploy-main
|
||||
|
||||
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
|
||||
12
.github/workflows/desktop-macos-screenshot.yml
vendored
12
.github/workflows/desktop-macos-screenshot.yml
vendored
@ -50,7 +50,6 @@ jobs:
|
||||
if: steps.display.outputs.available == 'true'
|
||||
with:
|
||||
node-version: 22
|
||||
cache: pnpm
|
||||
- run: pnpm install --frozen-lockfile
|
||||
if: steps.display.outputs.available == 'true'
|
||||
- run: pnpm --filter @rakazo/desktop build
|
||||
@ -77,14 +76,3 @@ jobs:
|
||||
pnpm --filter @rakazo/desktop exec playwright test
|
||||
--config e2e/playwright.config.ts setup.spec.ts
|
||||
--grep "first run asks whether to use a local or existing instance"
|
||||
- name: Upload native macOS setup screenshot
|
||||
if: always() && steps.display.outputs.available == 'true'
|
||||
continue-on-error: true
|
||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
|
||||
with:
|
||||
name: desktop-e2e-macos-screenshot
|
||||
path: |
|
||||
apps/desktop/e2e/screenshots/08-setup-macos-native-controls.png
|
||||
apps/desktop/e2e/screenshots/09-macos-dock.png
|
||||
if-no-files-found: warn
|
||||
retention-days: 1
|
||||
|
||||
55
.github/workflows/mobile-android-screenshots.yml
vendored
55
.github/workflows/mobile-android-screenshots.yml
vendored
@ -39,41 +39,39 @@ jobs:
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
# Runner-host caches only: never GitHub's cache or artifact storage.
|
||||
- name: Restore screenshot APK
|
||||
id: screenshot-apk
|
||||
uses: actions/cache/restore@0400d5f644dc74513175e3cd8d07132dd4860809 # v4.2.4
|
||||
with:
|
||||
path: apps/mobile/android/app/build/outputs/apk/release/app-release.apk
|
||||
key: mobile-android-apk-v2-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml', 'package.json', 'pnpm-workspace.yaml', 'turbo.json', 'tsconfig*.json', '.github/workflows/mobile-android-screenshots.yml', 'apps/mobile/package.json', 'apps/mobile/app.json', 'apps/mobile/app.config.ts', 'apps/mobile/metro.config.js', 'apps/mobile/metro-resolver.js', 'apps/mobile/assets/**', 'apps/mobile/app/**', 'apps/mobile/components/**', 'apps/mobile/lib/**', 'apps/mobile/modules/**', 'apps/mobile/plugins/**', 'packages/chat-ui/**', 'packages/contracts/**', 'packages/core/**', 'packages/ui-tokens/**') }}
|
||||
env:
|
||||
APK_KEY: ${{ hashFiles('pnpm-lock.yaml', 'package.json', 'pnpm-workspace.yaml', 'turbo.json', 'tsconfig*.json', '.github/workflows/mobile-android-screenshots.yml', 'apps/mobile/package.json', 'apps/mobile/app.json', 'apps/mobile/app.config.ts', 'apps/mobile/metro.config.js', 'apps/mobile/metro-resolver.js', 'apps/mobile/assets/**', 'apps/mobile/app/**', 'apps/mobile/components/**', 'apps/mobile/lib/**', 'apps/mobile/modules/**', 'apps/mobile/plugins/**', 'packages/chat-ui/**', 'packages/contracts/**', 'packages/core/**', 'packages/ui-tokens/**') }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
cache="$HOME/.cache/evoo-ci/mobile-android-apk"
|
||||
echo "path=$cache/v2-$APK_KEY.apk" >> "$GITHUB_OUTPUT"
|
||||
if [[ -f "$cache/v2-$APK_KEY.apk" ]]; then
|
||||
mkdir -p apps/mobile/android/app/build/outputs/apk/release
|
||||
cp "$cache/v2-$APK_KEY.apk" apps/mobile/android/app/build/outputs/apk/release/app-release.apk
|
||||
echo "cache-hit=true" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
- uses: pnpm/action-setup@f40ffcd9367d9f12939873eb1018b921a783ffaa # v4
|
||||
- uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6
|
||||
with:
|
||||
node-version: 22
|
||||
cache: pnpm
|
||||
- uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # v4.7.1
|
||||
with:
|
||||
distribution: temurin
|
||||
java-version: 17
|
||||
- uses: gradle/actions/setup-gradle@48b5f213c81028ace310571dc5ec0fbbca0b2947 # v4.4.3
|
||||
if: steps.screenshot-apk.outputs.cache-hit != 'true'
|
||||
with:
|
||||
cache-read-only: false
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Restore Maestro CLI
|
||||
id: maestro-cache
|
||||
uses: actions/cache@0400d5f644dc74513175e3cd8d07132dd4860809 # v4.2.4
|
||||
with:
|
||||
path: ~/.maestro
|
||||
key: maestro-${{ runner.os }}-2.6.1-3440825f
|
||||
|
||||
- name: Install pinned Maestro CLI
|
||||
if: steps.maestro-cache.outputs.cache-hit != 'true'
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [[ -f "$HOME/.maestro/.installed-2.6.1-3440825f" ]]; then
|
||||
exit 0
|
||||
fi
|
||||
maestro_tmp=$(mktemp -d)
|
||||
trap 'rm -rf "$maestro_tmp"' EXIT
|
||||
base_url=https://github.com/mobile-dev-inc/maestro/releases/download/cli-2.6.1
|
||||
@ -88,8 +86,10 @@ jobs:
|
||||
(cd "$maestro_tmp" && sha256sum --check --strict -)
|
||||
(cd "$maestro_tmp" && sha256sum --check --strict checksums_sha256.txt)
|
||||
unzip -q "$maestro_tmp/maestro.zip" -d "$maestro_tmp"
|
||||
rm -rf "$HOME/.maestro"
|
||||
mkdir -p "$HOME/.maestro"
|
||||
cp -R "$maestro_tmp/maestro/." "$HOME/.maestro/"
|
||||
touch "$HOME/.maestro/.installed-2.6.1-3440825f"
|
||||
|
||||
- name: Configure Maestro CLI
|
||||
run: |
|
||||
@ -112,10 +112,14 @@ jobs:
|
||||
|
||||
- name: Save screenshot APK
|
||||
if: steps.screenshot-apk.outputs.cache-hit != 'true'
|
||||
uses: actions/cache/save@0400d5f644dc74513175e3cd8d07132dd4860809 # v4.2.4
|
||||
with:
|
||||
path: apps/mobile/android/app/build/outputs/apk/release/app-release.apk
|
||||
key: ${{ steps.screenshot-apk.outputs.cache-primary-key }}
|
||||
env:
|
||||
APK_CACHE: ${{ steps.screenshot-apk.outputs.path }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
mkdir -p "$(dirname "$APK_CACHE")"
|
||||
find "$(dirname "$APK_CACHE")" -name '*.apk' -mtime +14 -delete
|
||||
cp apps/mobile/android/app/build/outputs/apk/release/app-release.apk "$APK_CACHE.partial"
|
||||
mv "$APK_CACHE.partial" "$APK_CACHE"
|
||||
|
||||
- name: Enable KVM
|
||||
run: |
|
||||
@ -151,12 +155,3 @@ jobs:
|
||||
MOBILE_RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
|
||||
MOBILE_SHA: ${{ github.sha }}
|
||||
run: bash scripts/publish-mobile-screenshot-gallery.sh
|
||||
|
||||
- name: Upload screenshots and diagnostics
|
||||
if: always()
|
||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
|
||||
with:
|
||||
name: mobile-android-screenshots-${{ github.run_id }}-${{ github.run_attempt }}
|
||||
path: test-report/mobile-screenshots
|
||||
if-no-files-found: error
|
||||
retention-days: 7
|
||||
|
||||
47
.github/workflows/playwright.yml
vendored
47
.github/workflows/playwright.yml
vendored
@ -25,8 +25,8 @@ on:
|
||||
required: false
|
||||
type: boolean
|
||||
default: false
|
||||
upload_artifacts:
|
||||
description: Retain the report and diagnostics as GitHub artifacts
|
||||
hand_off_report:
|
||||
description: Leave the report on the runner host for the trusted publish workflow
|
||||
required: false
|
||||
type: boolean
|
||||
default: false
|
||||
@ -54,7 +54,7 @@ jobs:
|
||||
playwright:
|
||||
name: Web E2E
|
||||
runs-on: [self-hosted, evoo-ci, Linux]
|
||||
timeout-minutes: 30
|
||||
timeout-minutes: 60
|
||||
concurrency:
|
||||
group: ${{ inputs.publish_report && 'playwright-publication' || format('playwright-{0}', github.run_id) }}
|
||||
cancel-in-progress: false
|
||||
@ -83,16 +83,12 @@ jobs:
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
cache: pnpm
|
||||
- run: pnpm install --frozen-lockfile
|
||||
- name: Clear stale Vite dependency cache
|
||||
run: rm -rf apps/web/node_modules/.vite node_modules/.vite
|
||||
- uses: actions/cache@v4
|
||||
with:
|
||||
path: ~/.cache/ms-playwright
|
||||
key: ${{ runner.os }}-playwright-${{ hashFiles('pnpm-lock.yaml') }}
|
||||
- name: Install Chromium
|
||||
timeout-minutes: 5
|
||||
# Browsers persist in ~/.cache/ms-playwright on the runner host; no GitHub cache.
|
||||
# Skip --with-deps: evo Linux runners keep Chromium's OS libraries installed,
|
||||
# and apt-get can stall until the job timeout on persistent self-hosted machines.
|
||||
run: pnpm --filter @rakazo/web exec playwright install chromium
|
||||
@ -149,7 +145,7 @@ jobs:
|
||||
if: always() && (steps.playwright_tests.outcome == 'success' || steps.playwright_tests.outcome == 'failure')
|
||||
run: pnpm --filter @rakazo/www e2e
|
||||
- name: Record Playwright result
|
||||
if: always() && inputs.upload_artifacts
|
||||
if: always() && inputs.hand_off_report
|
||||
env:
|
||||
PLAYWRIGHT_RESULT: ${{ steps.playwright_tests.outcome }}
|
||||
WWW_PLAYWRIGHT_RESULT: ${{ steps.www_playwright_tests.outcome }}
|
||||
@ -162,19 +158,28 @@ jobs:
|
||||
else
|
||||
printf 'failure\n' > test-report/e2e/outcome.txt
|
||||
fi
|
||||
- name: Upload Playwright artifacts
|
||||
if: always() && inputs.upload_artifacts
|
||||
- name: Hand off Playwright report on the runner host
|
||||
if: always() && inputs.hand_off_report
|
||||
continue-on-error: true
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: playwright-artifacts-${{ github.run_id }}-${{ github.run_attempt }}
|
||||
path: |
|
||||
playwright-report
|
||||
playwright-report-www
|
||||
apps/web/test-results
|
||||
test-report/e2e
|
||||
retention-days: 7
|
||||
if-no-files-found: warn
|
||||
# Never GitHub artifact storage: the publish workflow runs on the same evo host
|
||||
# and reads this archive as untrusted input.
|
||||
env:
|
||||
HANDOFF_ARCHIVE: ${{ github.run_id }}-${{ github.run_attempt }}.zip
|
||||
run: |
|
||||
set -euo pipefail
|
||||
handoff="$HOME/.cache/evoo-ci/playwright-handoff"
|
||||
mkdir -p "$handoff"
|
||||
python3 - "$handoff/$HANDOFF_ARCHIVE.partial" playwright-report playwright-report-www test-report/e2e <<'PY'
|
||||
import os, sys, zipfile
|
||||
with zipfile.ZipFile(sys.argv[1], "w", zipfile.ZIP_DEFLATED) as archive:
|
||||
for root in sys.argv[2:]:
|
||||
for directory, _, files in os.walk(root):
|
||||
for name in files:
|
||||
file = os.path.join(directory, name)
|
||||
if os.path.isfile(file) and not os.path.islink(file):
|
||||
archive.write(file)
|
||||
PY
|
||||
mv "$handoff/$HANDOFF_ARCHIVE.partial" "$handoff/$HANDOFF_ARCHIVE"
|
||||
- name: Publish Playwright visual report
|
||||
if: always() && inputs.publish_report
|
||||
env:
|
||||
|
||||
36
.github/workflows/publish-playwright-report.yml
vendored
36
.github/workflows/publish-playwright-report.yml
vendored
@ -37,16 +37,17 @@ jobs:
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
cache: pnpm
|
||||
- run: pnpm install --frozen-lockfile
|
||||
- name: Read Playwright job result
|
||||
id: playwright
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
RUN_ID: ${{ github.event.workflow_run.id }}
|
||||
RUN_ATTEMPT: ${{ github.event.workflow_run.run_attempt }}
|
||||
shell: bash
|
||||
run: |
|
||||
result="$(gh api "repos/${GITHUB_REPOSITORY}/actions/runs/${RUN_ID}/jobs?per_page=100" \
|
||||
# Read the attempt that finished: a re-run already in progress has no conclusion yet.
|
||||
result="$(gh api "repos/${GITHUB_REPOSITORY}/actions/runs/${RUN_ID}/attempts/${RUN_ATTEMPT}/jobs?per_page=100" \
|
||||
--jq '[.jobs[] | select(.name == "Web E2E / Web E2E") | .conclusion] | unique | if length == 1 then .[0] else error("expected exactly one Playwright job") end')"
|
||||
case "$result" in
|
||||
success|failure) ;;
|
||||
@ -75,38 +76,31 @@ jobs:
|
||||
echo "::warning::Could not resolve a pull request for ${HEAD_OWNER}:${HEAD_BRANCH} at ${HEAD_SHA}."
|
||||
fi
|
||||
echo "number=$number" >> "$GITHUB_OUTPUT"
|
||||
- name: Check for Playwright artifact
|
||||
- name: Take over Playwright report from the runner host
|
||||
id: artifact
|
||||
env:
|
||||
ARTIFACT_NAME: playwright-artifacts-${{ github.event.workflow_run.id }}-${{ github.event.workflow_run.run_attempt }}
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
HANDOFF_ARCHIVE: ${{ github.event.workflow_run.id }}-${{ github.event.workflow_run.run_attempt }}.zip
|
||||
PLAYWRIGHT_JOB_RESULT: ${{ steps.playwright.outputs.job_result }}
|
||||
RUN_ID: ${{ github.event.workflow_run.id }}
|
||||
shell: bash
|
||||
run: |
|
||||
artifacts="$(gh api --method GET \
|
||||
"repos/${GITHUB_REPOSITORY}/actions/runs/${RUN_ID}/artifacts?name=${ARTIFACT_NAME}")"
|
||||
count="$(jq --arg name "$ARTIFACT_NAME" \
|
||||
'[.artifacts[] | select(.name == $name and .expired == false)] | length' \
|
||||
<<<"$artifacts")"
|
||||
if [[ "$count" == "1" ]]; then
|
||||
# The Web E2E job left this archive on the same evo host; GitHub artifact storage is never used.
|
||||
handoff="$HOME/.cache/evoo-ci/playwright-handoff"
|
||||
mkdir -p "$handoff"
|
||||
find "$handoff" -type f -mtime +1 -delete
|
||||
if [[ -f "$handoff/$HANDOFF_ARCHIVE" ]]; then
|
||||
mv "$handoff/$HANDOFF_ARCHIVE" "$RUNNER_TEMP/playwright-artifacts.zip"
|
||||
echo "exists=true" >> "$GITHUB_OUTPUT"
|
||||
echo "id=$(jq -r --arg name "$ARTIFACT_NAME" '.artifacts[] | select(.name == $name and .expired == false) | .id' <<<"$artifacts")" >> "$GITHUB_OUTPUT"
|
||||
elif [[ "$count" == "0" && "$PLAYWRIGHT_JOB_RESULT" == "timed_out" ]]; then
|
||||
echo "::warning::The timed-out Playwright job ended before it uploaded an artifact. Publishing an empty gallery."
|
||||
elif [[ "$PLAYWRIGHT_JOB_RESULT" == "timed_out" ]]; then
|
||||
echo "::warning::The timed-out Playwright job ended before it handed off its report. Publishing an empty gallery."
|
||||
echo "exists=false" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "Expected exactly one unexpired Playwright artifact; found $count." >&2
|
||||
echo "Missing Playwright report $handoff/$HANDOFF_ARCHIVE on this runner host." >&2
|
||||
exit 1
|
||||
fi
|
||||
- name: Download Playwright artifacts
|
||||
- name: Extract Playwright report
|
||||
if: steps.artifact.outputs.exists == 'true'
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
ARTIFACT_ID: ${{ steps.artifact.outputs.id }}
|
||||
shell: bash
|
||||
run: |
|
||||
gh api "repos/${GITHUB_REPOSITORY}/actions/artifacts/${ARTIFACT_ID}/zip" > "$RUNNER_TEMP/playwright-artifacts.zip"
|
||||
# Keep untrusted files outside the checkout, dependencies, configuration,
|
||||
# and generated dashboard consumed by the comment step.
|
||||
python3 scripts/extract-playwright-artifacts.py \
|
||||
|
||||
176
.github/workflows/publish-server-image.yml
vendored
176
.github/workflows/publish-server-image.yml
vendored
@ -3,9 +3,26 @@ name: publish-server-image
|
||||
# Release tags publish the source-addressed image used by in-app updates plus the human-readable
|
||||
# semver tags. Main publishes `edge`. Pull requests build with a read-only token and an isolated
|
||||
# cache, so untrusted code can validate the images without receiving package-publish authority.
|
||||
# Self-hosted evo runners on one host share a Docker daemon and often ~/.docker; parallel matrix
|
||||
# jobs must use DOCKER_CONFIG under RUNNER_TEMP plus a per-job buildx builder name wired into
|
||||
# build-push-action, or setup-buildx --use races and one job's post cleanup stops another's BuildKit.
|
||||
# `ci-<run>-<image>-<arch>` handoff tags are extra aliases on the per-arch digest; GHCR does not
|
||||
# expose a safe tag-only delete API (DELETE /v2/.../manifests/<tag> is unsupported or digest-scoped),
|
||||
# so they are left in place and do not affect edge/sha-* manifests assembled in publish.
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
paths:
|
||||
- ".dockerignore"
|
||||
- ".github/workflows/publish-server-image.yml"
|
||||
- "apps/**"
|
||||
- "infra/compose/**"
|
||||
- "infra/sandboxes/computer/**"
|
||||
- "infra/updater/**"
|
||||
- "packages/**"
|
||||
- "package.json"
|
||||
- "pnpm-lock.yaml"
|
||||
- "pnpm-workspace.yaml"
|
||||
tags: ["v*"]
|
||||
pull_request:
|
||||
paths:
|
||||
@ -27,12 +44,55 @@ 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 reads digests from GHCR handoff tags.
|
||||
DOCKER_BUILD_RECORD_UPLOAD: false
|
||||
|
||||
concurrency:
|
||||
group: publish-server-image-${{ github.ref }}
|
||||
cancel-in-progress: false
|
||||
group: publish-server-image-${{ github.event_name == 'pull_request' && format('pr-{0}', github.event.pull_request.number) || github.ref }}
|
||||
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
|
||||
|
||||
jobs:
|
||||
changes:
|
||||
name: Detect image publish inputs
|
||||
runs-on: [self-hosted, evoo-ci]
|
||||
outputs:
|
||||
publish: ${{ steps.decide.outputs.publish }}
|
||||
steps:
|
||||
- uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5.1.0
|
||||
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
|
||||
with:
|
||||
persist-credentials: false
|
||||
fetch-depth: 0
|
||||
- uses: dorny/paths-filter@de90cc6fb38fc0963ad72b210f1f284cd68cea36 # v3.0.2
|
||||
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
|
||||
id: filter
|
||||
with:
|
||||
filters: |
|
||||
publish:
|
||||
- '.dockerignore'
|
||||
- '.github/workflows/publish-server-image.yml'
|
||||
- 'apps/**'
|
||||
- 'infra/compose/**'
|
||||
- 'infra/sandboxes/computer/**'
|
||||
- 'infra/updater/**'
|
||||
- 'packages/**'
|
||||
- 'package.json'
|
||||
- 'pnpm-lock.yaml'
|
||||
- 'pnpm-workspace.yaml'
|
||||
- name: Decide whether release jobs should run
|
||||
id: decide
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [[ "${{ github.event_name }}" == "workflow_dispatch" ]] || [[ "${{ github.ref }}" == refs/tags/v* ]]; then
|
||||
echo "publish=true" >> "$GITHUB_OUTPUT"
|
||||
elif [[ "${{ github.event_name }}" == "pull_request" ]]; then
|
||||
echo "publish=true" >> "$GITHUB_OUTPUT"
|
||||
elif [[ "${{ steps.filter.outputs.publish }}" == "true" ]]; then
|
||||
echo "publish=true" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "publish=false" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
installer-smoke:
|
||||
name: Compose installer smokes
|
||||
if: github.event_name == 'pull_request'
|
||||
@ -70,7 +130,16 @@ jobs:
|
||||
- uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5.1.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0
|
||||
- name: Prepare isolated Docker config directory
|
||||
run: |
|
||||
set -euo pipefail
|
||||
cfg="${RUNNER_TEMP}/docker-config"
|
||||
mkdir -p "$cfg"
|
||||
echo "DOCKER_CONFIG=${cfg}" >> "$GITHUB_ENV"
|
||||
- id: buildx
|
||||
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0
|
||||
with:
|
||||
name: ci-pr-${{ github.event.pull_request.number }}-${{ matrix.name }}
|
||||
- name: Resolve the lowercase image namespace
|
||||
id: ns
|
||||
# GHCR rejects repository names with capitals; the GitHub repository slug has them.
|
||||
@ -80,28 +149,37 @@ jobs:
|
||||
with:
|
||||
images: ghcr.io/${{ steps.ns.outputs.repo }}/${{ matrix.name }}
|
||||
tags: type=sha,prefix=sha-
|
||||
- name: Locate the main build cache on this runner host
|
||||
id: cache
|
||||
# Build caches stay on the evo host; GitHub's cache service is never used.
|
||||
run: echo "dir=$HOME/.cache/evoo-ci/buildx/${{ matrix.name }}-amd64" >> "$GITHUB_OUTPUT"
|
||||
- uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6.19.2
|
||||
with:
|
||||
builder: ${{ steps.buildx.outputs.name }}
|
||||
context: ${{ matrix.context }}
|
||||
file: ${{ matrix.dockerfile }}
|
||||
push: false
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
build-args: ${{ matrix.name == 'app' && format('GIT_SHA={0}', github.sha) || '' }}
|
||||
cache-from: type=gha,scope=${{ matrix.name }}
|
||||
cache-to: type=gha,mode=max,scope=pr-${{ github.event.pull_request.number }}-${{ matrix.name }}
|
||||
# Pull requests only read the cache main writes.
|
||||
cache-from: type=local,src=${{ steps.cache.outputs.dir }}
|
||||
provenance: false
|
||||
sbom: false
|
||||
|
||||
build:
|
||||
name: Build ${{ matrix.name }} ${{ matrix.arch }}
|
||||
if: github.event_name != 'pull_request'
|
||||
needs: changes
|
||||
if: github.event_name != 'pull_request' && needs.changes.outputs.publish == 'true'
|
||||
# Each architecture builds natively on its own runner and pushes by digest only.
|
||||
# amd64 on evo Linux runners. arm64 is paused: the macOS runners have no Docker buildx,
|
||||
# and QEMU emulation took hours for the desktop-sized computer image.
|
||||
# See docs/self-host.md (Published images and tags).
|
||||
runs-on: ${{ matrix.runner }}
|
||||
timeout-minutes: 90
|
||||
concurrency:
|
||||
group: publish-server-image-build-${{ github.ref }}-${{ matrix.name }}-${{ matrix.arch }}
|
||||
cancel-in-progress: ${{ github.ref == 'refs/heads/main' }}
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
@ -128,7 +206,16 @@ jobs:
|
||||
- uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5.1.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0
|
||||
- name: Prepare isolated Docker config directory
|
||||
run: |
|
||||
set -euo pipefail
|
||||
cfg="${RUNNER_TEMP}/docker-config"
|
||||
mkdir -p "$cfg"
|
||||
echo "DOCKER_CONFIG=${cfg}" >> "$GITHUB_ENV"
|
||||
- id: buildx
|
||||
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0
|
||||
with:
|
||||
name: ci-${{ github.run_id }}-${{ github.run_attempt }}-${{ matrix.name }}-${{ matrix.arch }}
|
||||
- name: Log in to GHCR
|
||||
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0
|
||||
with:
|
||||
@ -145,9 +232,14 @@ jobs:
|
||||
# Labels only; the tagged manifest is assembled in the publish job.
|
||||
images: ghcr.io/${{ steps.ns.outputs.repo }}/${{ matrix.name }}
|
||||
tags: type=sha,prefix=sha-
|
||||
- name: Locate the build cache on this runner host
|
||||
id: cache
|
||||
# Build caches stay on the evo host; GitHub's cache service is never used.
|
||||
run: echo "dir=$HOME/.cache/evoo-ci/buildx/${{ matrix.name }}-${{ matrix.arch }}" >> "$GITHUB_OUTPUT"
|
||||
- id: build
|
||||
uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6.19.2
|
||||
with:
|
||||
builder: ${{ steps.buildx.outputs.name }}
|
||||
context: ${{ matrix.context }}
|
||||
file: ${{ matrix.dockerfile }}
|
||||
platforms: linux/${{ matrix.arch }}
|
||||
@ -156,33 +248,37 @@ jobs:
|
||||
outputs: type=image,name=ghcr.io/${{ steps.ns.outputs.repo }}/${{ matrix.name }},push-by-digest=true,name-canonical=true,push=true
|
||||
# GET /health can report the exact source commit without a deployment-supplied override.
|
||||
build-args: ${{ matrix.name == 'app' && format('GIT_SHA={0}', github.sha) || '' }}
|
||||
cache-from: type=gha,scope=${{ matrix.name }}-${{ matrix.arch }}
|
||||
cache-to: type=gha,mode=max,scope=${{ matrix.name }}-${{ matrix.arch }}
|
||||
cache-from: type=local,src=${{ steps.cache.outputs.dir }}
|
||||
cache-to: type=local,mode=max,dest=${{ steps.cache.outputs.dir }}-new
|
||||
provenance: mode=max
|
||||
sbom: true
|
||||
- name: Record the architecture digest
|
||||
id: digest
|
||||
- name: Replace the build cache
|
||||
# A local cache only grows; swapping in the fresh export drops layers this build no longer uses.
|
||||
env:
|
||||
DIGEST: ${{ steps.build.outputs.digest }}
|
||||
CACHE_DIR: ${{ steps.cache.outputs.dir }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
digest_dir="${RUNNER_TEMP}/digest"
|
||||
mkdir -p "$digest_dir"
|
||||
touch "${digest_dir}/${DIGEST#sha256:}"
|
||||
echo "dir=${digest_dir}" >> "$GITHUB_OUTPUT"
|
||||
- uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
|
||||
with:
|
||||
name: digest-${{ matrix.name }}-${{ matrix.arch }}
|
||||
path: ${{ steps.digest.outputs.dir }}/*
|
||||
if-no-files-found: error
|
||||
retention-days: 1
|
||||
rm -rf "$CACHE_DIR"
|
||||
mv "$CACHE_DIR-new" "$CACHE_DIR"
|
||||
- name: Tag digest for publish job handoff
|
||||
env:
|
||||
DIGEST: ${{ steps.build.outputs.digest }}
|
||||
IMAGE: ghcr.io/${{ steps.ns.outputs.repo }}/${{ matrix.name }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
docker buildx imagetools create \
|
||||
-t "${IMAGE}:ci-${GITHUB_RUN_ID}-${{ matrix.name }}-${{ matrix.arch }}" \
|
||||
"${IMAGE}@${DIGEST}"
|
||||
|
||||
publish:
|
||||
name: Publish ${{ matrix.name }} image
|
||||
if: github.event_name != 'pull_request'
|
||||
needs: build
|
||||
if: github.event_name != 'pull_request' && needs.changes.outputs.publish == 'true'
|
||||
needs: [changes, build]
|
||||
runs-on: [self-hosted, evoo-ci, Linux]
|
||||
timeout-minutes: 15
|
||||
concurrency:
|
||||
group: publish-server-image-publish-${{ github.ref == 'refs/heads/main' && 'main' || github.ref }}-${{ matrix.name }}
|
||||
cancel-in-progress: false
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
@ -196,15 +292,16 @@ jobs:
|
||||
- name: updater
|
||||
- name: computer
|
||||
steps:
|
||||
- name: Prepare an isolated digest directory
|
||||
id: digest_dir
|
||||
run: echo "path=${RUNNER_TEMP}/digests" >> "$GITHUB_OUTPUT"
|
||||
- uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4
|
||||
- name: Prepare isolated Docker config directory
|
||||
run: |
|
||||
set -euo pipefail
|
||||
cfg="${RUNNER_TEMP}/docker-config"
|
||||
mkdir -p "$cfg"
|
||||
echo "DOCKER_CONFIG=${cfg}" >> "$GITHUB_ENV"
|
||||
- id: buildx
|
||||
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0
|
||||
with:
|
||||
pattern: digest-${{ matrix.name }}-*
|
||||
path: ${{ steps.digest_dir.outputs.path }}
|
||||
merge-multiple: true
|
||||
- uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0
|
||||
name: ci-${{ github.run_id }}-${{ github.run_attempt }}-publish-${{ matrix.name }}
|
||||
- name: Log in to GHCR
|
||||
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0
|
||||
with:
|
||||
@ -215,6 +312,21 @@ jobs:
|
||||
id: ns
|
||||
# GHCR rejects repository names with capitals; the GitHub repository slug has them.
|
||||
run: echo "repo=${GITHUB_REPOSITORY,,}" >> "$GITHUB_OUTPUT"
|
||||
- name: Collect architecture digests from registry handoff tags
|
||||
id: digest_dir
|
||||
env:
|
||||
IMAGE: ghcr.io/${{ steps.ns.outputs.repo }}/${{ matrix.name }}
|
||||
IMAGE_NAME: ${{ matrix.name }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
digest_dir="${RUNNER_TEMP}/digests"
|
||||
mkdir -p "$digest_dir"
|
||||
for arch in amd64; do
|
||||
handoff="${IMAGE}:ci-${GITHUB_RUN_ID}-${IMAGE_NAME}-${arch}"
|
||||
digest="$(docker buildx imagetools inspect "$handoff" --format '{{.Manifest.Digest}}')"
|
||||
touch "${digest_dir}/${digest#sha256:}"
|
||||
done
|
||||
echo "path=${digest_dir}" >> "$GITHUB_OUTPUT"
|
||||
- id: meta
|
||||
uses: docker/metadata-action@c299e40c65443455700f0fdfc63efafe5b349051 # v5.10.0
|
||||
with:
|
||||
|
||||
88
.github/workflows/release-desktop.yml
vendored
88
.github/workflows/release-desktop.yml
vendored
@ -18,6 +18,8 @@ jobs:
|
||||
validate:
|
||||
name: Validate stable release
|
||||
runs-on: [self-hosted, evoo-ci, Linux]
|
||||
permissions:
|
||||
contents: write
|
||||
outputs:
|
||||
tag: ${{ steps.release.outputs.tag }}
|
||||
version: ${{ steps.release.outputs.version }}
|
||||
@ -95,17 +97,32 @@ jobs:
|
||||
set -euo pipefail
|
||||
include='[{"runner":["self-hosted","evoo-ci","macOS"],"artifact":"macos"},{"runner":["self-hosted","evoo-ci","Linux"],"artifact":"linux"}]'
|
||||
if [[ "$WINDOWS_SIGNING" == "true" ]]; then
|
||||
# No self-hosted Windows runner; keep GitHub-hosted only when signing credentials exist.
|
||||
include="$(jq -c '. + [{"runner":"windows-2022","artifact":"windows"}]' <<<"$include")"
|
||||
# Internal runners only: Windows waits for a self-hosted Windows runner, never a GitHub-hosted one.
|
||||
include="$(jq -c '. + [{"runner":["self-hosted","evoo-ci","Windows"],"artifact":"windows"}]' <<<"$include")"
|
||||
fi
|
||||
echo "include=$include" >> "$GITHUB_OUTPUT"
|
||||
echo "windows=$WINDOWS_SIGNING" >> "$GITHUB_OUTPUT"
|
||||
- name: Open the draft release the platform builds fill
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
RELEASE_TAG: ${{ steps.release.outputs.tag }}
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
# Builds hand their files to this draft, never to GitHub Actions artifact storage.
|
||||
if [[ "$(gh api "repos/${GITHUB_REPOSITORY}/releases?per_page=100" \
|
||||
--jq "[.[] | select(.draft and .tag_name == \"${RELEASE_TAG}\")] | length")" != "0" ]]; then
|
||||
gh release delete "$RELEASE_TAG" --yes
|
||||
fi
|
||||
gh release create "$RELEASE_TAG" --draft --generate-notes --title "Rakazo $RELEASE_TAG" --verify-tag
|
||||
|
||||
build:
|
||||
name: Build signed ${{ matrix.artifact }} artifacts
|
||||
needs: validate
|
||||
runs-on: ${{ matrix.runner }}
|
||||
timeout-minutes: 60
|
||||
permissions:
|
||||
contents: write
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
@ -172,8 +189,8 @@ jobs:
|
||||
xcrun stapler validate "$app"
|
||||
spctl --assess --type execute --verbose=2 "$app"
|
||||
grep -Fqx "provider: github" "$app/Contents/Resources/app-update.yml"
|
||||
grep -Fqx "owner: elie222" "$app/Contents/Resources/app-update.yml"
|
||||
grep -Fqx "repo: rakazo" "$app/Contents/Resources/app-update.yml"
|
||||
grep -Fqx "owner: cynarAI" "$app/Contents/Resources/app-update.yml"
|
||||
grep -Fqx "repo: evOo-local" "$app/Contents/Resources/app-update.yml"
|
||||
|
||||
- name: Require Windows signing credentials
|
||||
if: runner.os == 'Windows'
|
||||
@ -214,7 +231,7 @@ jobs:
|
||||
throw "Windows update config is not bound to the signing publisher."
|
||||
}
|
||||
$feed = Get-Content -Raw $config
|
||||
foreach ($expected in @("provider: github", "owner: elie222", "repo: rakazo")) {
|
||||
foreach ($expected in @("provider: github", "owner: cynarAI", "repo: evOo-local")) {
|
||||
if ($feed -notmatch "(?m)^$([regex]::Escape($expected))\r?$") {
|
||||
throw "Windows update config missing '$expected'."
|
||||
}
|
||||
@ -239,25 +256,32 @@ jobs:
|
||||
test -n "$config"
|
||||
test -f "$config"
|
||||
grep -Fqx "provider: github" "$config"
|
||||
grep -Fqx "owner: elie222" "$config"
|
||||
grep -Fqx "repo: rakazo" "$config"
|
||||
grep -Fqx "owner: cynarAI" "$config"
|
||||
grep -Fqx "repo: evOo-local" "$config"
|
||||
test -f apps/desktop/out/latest-linux.yml
|
||||
grep -Fqx "version: $RELEASE_VERSION" apps/desktop/out/latest-linux.yml
|
||||
|
||||
- name: Retain installers and updater metadata
|
||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
|
||||
with:
|
||||
name: desktop-${{ matrix.artifact }}-${{ needs.validate.outputs.sha }}
|
||||
path: |
|
||||
- name: Hand installers and updater metadata to the draft release
|
||||
shell: bash
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
RELEASE_TAG: ${{ needs.validate.outputs.tag }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
shopt -s nullglob
|
||||
files=(
|
||||
apps/desktop/out/*.AppImage
|
||||
apps/desktop/out/*.blockmap
|
||||
apps/desktop/out/*.dmg
|
||||
apps/desktop/out/*.exe
|
||||
apps/desktop/out/latest*.yml
|
||||
apps/desktop/out/*.zip
|
||||
if-no-files-found: error
|
||||
compression-level: 0
|
||||
retention-days: 7
|
||||
)
|
||||
if [[ ${#files[@]} -eq 0 ]]; then
|
||||
echo "The build produced no release files." >&2
|
||||
exit 1
|
||||
fi
|
||||
bash scripts/upload-release-assets.sh "${files[@]}"
|
||||
|
||||
publish:
|
||||
name: Attest and publish complete release
|
||||
@ -275,11 +299,11 @@ jobs:
|
||||
fetch-depth: 0
|
||||
persist-credentials: false
|
||||
ref: ${{ needs.validate.outputs.sha }}
|
||||
- uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4
|
||||
with:
|
||||
pattern: desktop-*-${{ needs.validate.outputs.sha }}
|
||||
path: release-artifacts
|
||||
merge-multiple: true
|
||||
- name: Collect every platform from the draft release
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
RELEASE_TAG: ${{ needs.validate.outputs.tag }}
|
||||
run: gh release download "$RELEASE_TAG" --dir release-artifacts
|
||||
- name: Verify the complete stable update feed
|
||||
env:
|
||||
RELEASE_SHA: ${{ needs.validate.outputs.sha }}
|
||||
@ -323,26 +347,34 @@ jobs:
|
||||
uses: actions/attest-build-provenance@977bb373ede98d70efdf65b84cb5f73e068dcc2a # v3
|
||||
with:
|
||||
subject-path: release-artifacts/*
|
||||
- name: Create draft and upload every platform
|
||||
id: create_release
|
||||
- name: Add checksums to the draft
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
RELEASE_TAG: ${{ needs.validate.outputs.tag }}
|
||||
run: >-
|
||||
gh release create "$RELEASE_TAG" release-artifacts/*
|
||||
--draft --generate-notes --title "Rakazo $RELEASE_TAG" --verify-tag
|
||||
run: gh release upload "$RELEASE_TAG" release-artifacts/SHA256SUMS --clobber
|
||||
- name: Publish the completed release
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
RELEASE_TAG: ${{ needs.validate.outputs.tag }}
|
||||
run: gh release edit "$RELEASE_TAG" --draft=false --latest
|
||||
- name: Remove an incomplete draft
|
||||
if: failure() && steps.create_release.outcome != 'skipped'
|
||||
|
||||
discard:
|
||||
name: Remove an incomplete draft
|
||||
needs: [validate, build, publish]
|
||||
if: failure() && needs.validate.result == 'success'
|
||||
runs-on: [self-hosted, evoo-ci, Linux]
|
||||
timeout-minutes: 5
|
||||
permissions:
|
||||
contents: write
|
||||
steps:
|
||||
- name: Delete the draft release
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
GH_REPO: ${{ github.repository }}
|
||||
RELEASE_TAG: ${{ needs.validate.outputs.tag }}
|
||||
shell: bash
|
||||
run: |
|
||||
if [[ "$(gh api "repos/${GITHUB_REPOSITORY}/releases/tags/${RELEASE_TAG}" --jq .draft 2>/dev/null)" == "true" ]]; then
|
||||
if [[ "$(gh api "repos/${GITHUB_REPOSITORY}/releases?per_page=100" \
|
||||
--jq "[.[] | select(.draft and .tag_name == \"${RELEASE_TAG}\")] | length")" != "0" ]]; then
|
||||
gh release delete "$RELEASE_TAG" --yes
|
||||
fi
|
||||
|
||||
1
.github/workflows/topology-verification.yml
vendored
1
.github/workflows/topology-verification.yml
vendored
@ -19,7 +19,6 @@ jobs:
|
||||
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
|
||||
with:
|
||||
node-version: 22
|
||||
cache: pnpm
|
||||
- run: pnpm install --frozen-lockfile
|
||||
- run: pnpm test:topology
|
||||
- name: Replay computer tools against real Chromium without inference
|
||||
|
||||
@ -11,6 +11,7 @@
|
||||
- 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.
|
||||
- CI runs only on the self-hosted `evoo-ci` runners and never consumes GitHub Actions resources: no GitHub-hosted runners, no Actions artifact storage (`upload-artifact`, `download-artifact`), and no GitHub cache service (`actions/cache`, setup-node `cache:`, buildx `type=gha`). Keep caches and job handoffs on the runner host; `infra/updater/src/github-actions-budget.test.ts` enforces this.
|
||||
- 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.
|
||||
|
||||
114
PROJECT.md
Normal file
114
PROJECT.md
Normal file
@ -0,0 +1,114 @@
|
||||
# Project: Dual-Surface-View in evOo (`apps/web`)
|
||||
|
||||
## Architecture
|
||||
evOo (`apps/web`) organizes the workspace into two primary surfaces:
|
||||
1. **Mission Stream**: The chat, agent transcript (`data-testid="transcript"`), run steps (`data-testid="run-steps"`), and composer inside `<main>`.
|
||||
2. **Living Canvas**: The computer surface (`data-testid="computer-preview"`), workspace files explorer (`WorkspaceFilesView`), code inspector, in-app browser, and artifact viewer inside `<aside data-testid="side-panel">`.
|
||||
|
||||
In desktop viewports (>= 768px `md`), these two surfaces operate as equal, first-class split surfaces within `Shell.tsx`:
|
||||
- Positioned side-by-side without modal dialog occlusion.
|
||||
- Separated by an interactive `DualSurfaceDivider` supporting fluid pointer drag-resizing, double-click snap to 50/50 balance, and keyboard arrow adjustments.
|
||||
- Controlled via quick-switch header preset controls: Focus Stream (100/0), Dual-Surface Split (50/50), Focus Canvas (0/100), and a side-swapping toggle (Stream-first vs. Canvas-first).
|
||||
- Configured by persisted preferences (`localStorage` via `dual-surface-pref.ts`) with safe defaults and bounded numeric clamping.
|
||||
- Responsive degradation (< 768px `md`): Automatically transitions to stacked/drawer/single-surface mode without mutating desktop split ratio preferences.
|
||||
- Monochrome design system adhering to `@rakazo/ui-tokens` with zero hardcoded hex or arbitrary CSS values.
|
||||
- Internationalized using LinguiJS with 100% German (`de`) translation coverage.
|
||||
|
||||
```
|
||||
+-----------------------------------------------------------------------------------+
|
||||
| Top Navigation Bar: [Bots Toggle] ... [Preset: 100/0 | 50/50 | 0/100] [Swap] ... |
|
||||
+----------------------------------------------------+---+--------------------------+
|
||||
| Surface A (e.g. Mission Stream / Chat) | D | Surface B (Living Canvas)|
|
||||
| | I | |
|
||||
| - Transcript (`data-testid="transcript"`) | V | - Computer Preview |
|
||||
| - Run steps (`data-testid="run-steps"`) | I | - Workspace Explorer |
|
||||
| - Composer | D | - Code Inspector |
|
||||
| | E | (`data-testid="side-panel")|
|
||||
| | R | |
|
||||
+----------------------------------------------------+---+--------------------------+
|
||||
```
|
||||
|
||||
## Feature Inventory
|
||||
| # | Feature | Description | Milestone | Source |
|
||||
|---|---------|-------------|-----------|--------|
|
||||
| 1 | Preferences Storage & Schema | `dual-surface-pref.ts` managing `splitRatio` (0.20-0.80, default 0.50), `layoutMode` (`split`, `stream`, `canvas`), `orientation` (`stream-first`, `canvas-first`) with localStorage read/write and bounds clamping | M1 | ORIGINAL_REQUEST §R3 |
|
||||
| 2 | Layout Hook & State Coordination | `useDualSurfaceLayout.ts` React hook handling layout state, preset toggles, orientation flip, and media query synchronization | M1 | ORIGINAL_REQUEST §R1, R3 |
|
||||
| 3 | Fluid Drag-Resize & Snap Divider | `DualSurfaceDivider.tsx` + `useDualSurfaceResize.ts` with pointer capture, hover/active semantic tokens, bounds clamping, double-click 50/50 snap, and ARIA separator attributes | M2 | ORIGINAL_REQUEST §R2, R4 |
|
||||
| 4 | Header Preset Controls & Orientation Swap | `DualSurfaceHeaderControls.tsx` with quick-switch presets (100/0, 50/50, 0/100) and Swap Orientation toggle, styled with semantic tokens and full accessibility | M2 | ORIGINAL_REQUEST §R1, R2 |
|
||||
| 5 | Shell Flex Integration & Surface Swap | Coordinated flex container in `Shell.tsx` hosting `<main>` and `<aside data-testid="side-panel">`, supporting dynamic order swapping while preserving existing test IDs | M3 | ORIGINAL_REQUEST §R1, R2, R4 |
|
||||
| 6 | Responsive Degradation (< md) | Seamless fallback on viewports < 768px to stacked/single-surface navigation without corrupting persisted desktop preferences | M3 | ORIGINAL_REQUEST §R3 |
|
||||
| 7 | Design System & Token Compliance | Exclusively semantic tokens from `@rakazo/ui-tokens` (`bg-border`, `hover:bg-accent`, `active:bg-primary`, `ring-ring`), monochrome styling, no hardcoded hex/arbitrary css | M3 | ORIGINAL_REQUEST §R4 |
|
||||
| 8 | Localization (German Translations) | Lingui message extraction, full German translations in `messages.po`, clean `intl:extract` and `intl:compile` | M3 | ORIGINAL_REQUEST §Acceptance Criteria |
|
||||
| 9 | E2E Testing & Adversarial Hardening | Comprehensive test suite (Tiers 1-4) and adversarial hardening (Tier 5), 100% green tests (78/78 suites baseline + new tests) and 0 TS errors | M4 | ORIGINAL_REQUEST §Acceptance Criteria |
|
||||
|
||||
## Milestones
|
||||
| # | Name | Scope | Dependencies | Status |
|
||||
|---|------|-------|-------------|--------|
|
||||
| M1 | Preferences, Layout Hook & State Management | `dual-surface-pref.ts`, `useDualSurfaceLayout.ts`, unit tests | none | DONE |
|
||||
| M2 | Interactive Divider & Header Preset Controls | `DualSurfaceDivider.tsx`, `useDualSurfaceResize.ts`, `dual-surface-header-controls.tsx`, unit/DOM tests | M1 | DONE |
|
||||
| M3 | Shell Integration, Responsive Degradation & Localization | `Shell.tsx` layout refactoring, responsive fallback, German translations in `messages.po`, preserve test IDs | M2 | DONE |
|
||||
| M4 | E2E Test Suite (Tiers 1-4) & Adversarial Hardening (Tier 5) | Comprehensive E2E test suite execution, 100% pass, forensic integrity audit | M3, E2E Track | IN_PROGRESS |
|
||||
|
||||
## Interface Contracts
|
||||
### `dual-surface-pref.ts`
|
||||
```typescript
|
||||
export type DualSurfaceLayoutMode = "split" | "stream" | "canvas";
|
||||
export type DualSurfaceOrientation = "stream-first" | "canvas-first";
|
||||
|
||||
export interface DualSurfacePreferences {
|
||||
splitRatio: number; // 0.20 to 0.80, default 0.50
|
||||
layoutMode: DualSurfaceLayoutMode;
|
||||
orientation: DualSurfaceOrientation;
|
||||
}
|
||||
|
||||
export function readDualSurfacePreferences(): DualSurfacePreferences;
|
||||
export function writeDualSurfacePreferences(prefs: Partial<DualSurfacePreferences>): void;
|
||||
export function clampSplitRatio(ratio: number): number;
|
||||
```
|
||||
|
||||
### `useDualSurfaceLayout.ts`
|
||||
```typescript
|
||||
export interface DualSurfaceLayoutState {
|
||||
splitRatio: number;
|
||||
layoutMode: DualSurfaceLayoutMode;
|
||||
orientation: DualSurfaceOrientation;
|
||||
isMobile: boolean; // < 768px (md)
|
||||
setSplitRatio: (ratio: number) => void;
|
||||
setLayoutMode: (mode: DualSurfaceLayoutMode) => void;
|
||||
setOrientation: (orientation: DualSurfaceOrientation) => void;
|
||||
toggleOrientation: () => void;
|
||||
resetSplit: () => void; // Snaps to 0.50
|
||||
}
|
||||
```
|
||||
|
||||
### `DualSurfaceDivider.tsx`
|
||||
```typescript
|
||||
export interface DualSurfaceDividerProps {
|
||||
splitRatio: number;
|
||||
onResize: (newRatio: number) => void;
|
||||
onReset: () => void; // Double-click snap
|
||||
disabled?: boolean;
|
||||
}
|
||||
```
|
||||
|
||||
### `DualSurfaceHeaderControls.tsx`
|
||||
```typescript
|
||||
export interface DualSurfaceHeaderControlsProps {
|
||||
layoutMode: DualSurfaceLayoutMode;
|
||||
orientation: DualSurfaceOrientation;
|
||||
onSelectMode: (mode: DualSurfaceLayoutMode) => void;
|
||||
onToggleOrientation: () => void;
|
||||
disabled?: boolean;
|
||||
}
|
||||
```
|
||||
|
||||
## Code Layout
|
||||
- `apps/web/src/lib/dual-surface-pref.ts`: Preference reading, writing, and boundary clamping.
|
||||
- `apps/web/src/lib/dual-surface-pref.test.ts`: Unit tests for storage and mathematical boundaries.
|
||||
- `apps/web/src/hooks/useDualSurfaceLayout.ts`: Layout state and responsive lifecycle management.
|
||||
- `apps/web/src/hooks/useDualSurfaceResize.ts`: Pointer capture and keyboard navigation hook for divider.
|
||||
- `apps/web/src/components/DualSurfaceDivider.tsx`: Accessible interactive divider component.
|
||||
- `apps/web/src/pages/shell/dual-surface-header-controls.tsx`: Quick-switch header presets and swap buttons.
|
||||
- `apps/web/src/pages/Shell.tsx`: Integration into application root layout.
|
||||
- `apps/web/src/locales/de/messages.po`: German translation catalog entries.
|
||||
- `apps/web/src/tests/dual-surface-view.test.tsx`: Component integration and DOM interaction tests.
|
||||
@ -49,7 +49,7 @@ Full local notes: **[docs/LOCAL-SETUP.md](./docs/LOCAL-SETUP.md)**
|
||||
- TypeScript monorepo (pnpm + Turborepo)
|
||||
- React 19 / Vite / Tailwind (web), Electron (desktop), Expo (mobile)
|
||||
- Hono + oRPC, PostgreSQL + Prisma, Better Auth, Graphile Worker
|
||||
- Pi model routing, Docker sandboxes, Voice (Kokoro local / BYO keys)
|
||||
- Pi model routing, Docker sandboxes, Voice (Kokoro, local)
|
||||
|
||||
## Antigravity tools kept on this branch
|
||||
|
||||
@ -68,7 +68,7 @@ docker run -p 8880:8880 ghcr.io/remsky/kokoro-fastapi-cpu:latest
|
||||
export KOKORO_BASE_URL=http://127.0.0.1:8880
|
||||
```
|
||||
|
||||
Details: [docs/kokoro-voice.md](./docs/kokoro-voice.md)
|
||||
German voices come from a local Piper server (`PIPER_BASE_URL`). Details: [docs/kokoro-voice.md](./docs/kokoro-voice.md)
|
||||
|
||||
## Tech debt (brand / packages)
|
||||
|
||||
|
||||
135
TEST_INFRA.md
Normal file
135
TEST_INFRA.md
Normal file
@ -0,0 +1,135 @@
|
||||
# E2E Test Infrastructure: evOo Voice-First Autonomous Agent OS (Dual Track)
|
||||
|
||||
## 1. Test Philosophy & Principles
|
||||
- **Opaque-Box & Requirement-Driven**: Derived strictly from user specifications in `ORIGINAL_REQUEST.md` (§R1, §R2, §R3) and architecture specifications in `orchestrator_2/PROJECT.md` (§F1–§F12).
|
||||
- **Interface Contract Verification**: Validates concrete contracts for `StreamingVoiceFrame`, `StreamingVoiceClient`, `ActionQueueStep`, `DynamicActionQueue`, and `RoutineNotificationPolicy`.
|
||||
- **Offline & Deterministic Execution**: Zero external cloud or hosted vendor dependencies; uses simulated Web Audio, deterministic PCM frame generators, and pure React 19 testing harnesses.
|
||||
- **Strict Linting & Design System Compliance**: Conforms strictly to `@rakazo/ui-tokens` monochrome tokens, zero arbitrary hex colors, top-level `import type`, and clean Biome formatting.
|
||||
|
||||
---
|
||||
|
||||
## 2. Test Architecture & Runner Commands
|
||||
|
||||
### Execution Commands
|
||||
1. **Run All E2E OS Test Suites (139 tests)**:
|
||||
```bash
|
||||
pnpm vitest run apps/web/src/tests/e2e-voice-first-foundation.test.ts apps/web/src/tests/e2e-autonomous-agency-queues.test.ts apps/web/src/tests/e2e-minimalist-tactile-ux.test.tsx apps/web/src/tests/e2e-cross-feature-workflows.test.ts
|
||||
```
|
||||
2. **Run Individual Feature Suites**:
|
||||
- Voice Foundation (F1–F4):
|
||||
```bash
|
||||
pnpm vitest run apps/web/src/tests/e2e-voice-first-foundation.test.ts
|
||||
```
|
||||
- Autonomous Agency & Queues (F5–F8):
|
||||
```bash
|
||||
pnpm vitest run apps/web/src/tests/e2e-autonomous-agency-queues.test.ts
|
||||
```
|
||||
- Minimalist Tactile UX & Onboarding (F9–F12):
|
||||
```bash
|
||||
pnpm vitest run apps/web/src/tests/e2e-minimalist-tactile-ux.test.tsx
|
||||
```
|
||||
- Cross-Feature & Real-World Workflows (T3 & T4):
|
||||
```bash
|
||||
pnpm vitest run apps/web/src/tests/e2e-cross-feature-workflows.test.ts
|
||||
```
|
||||
3. **TypeScript Compilation Check**:
|
||||
```bash
|
||||
pnpm --filter @rakazo/web check
|
||||
```
|
||||
4. **Code Quality & Linter**:
|
||||
```bash
|
||||
pnpm biome check apps/web/src/tests/e2e-*.ts* apps/web/src/tests/fixtures/*.ts
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. Feature Inventory & Test Coverage Matrix
|
||||
|
||||
| # | Feature | Scope & Requirement | Test File | T1 | T2 | T3 | T4 | Total Tests |
|
||||
|---|---------|---------------------|-----------|:--:|:--:|:--:|:--:|:-----------:|
|
||||
| **F1** | Streaming Audio Wire & Low-Latency VAD | ORIGINAL_REQUEST §R1 (<120ms framing, PCM16, VAD) | `e2e-voice-first-foundation.test.ts` | 5 | 5 | ✓ | ✓ | 10+ |
|
||||
| **F2** | Full-Duplex Audio & Barge-In Interruption | ORIGINAL_REQUEST §R1 (Continuous AEC, barge-in mute) | `e2e-voice-first-foundation.test.ts` | 5 | 5 | ✓ | ✓ | 10+ |
|
||||
| **F3** | Incremental Utterance Streamer & Invite Strip | ORIGINAL_REQUEST §R1 (Clause pipelining, no invite delays) | `e2e-voice-first-foundation.test.ts` | 5 | 5 | ✓ | ✓ | 10+ |
|
||||
| **F4** | Cross-Platform Earcons & Mobile Audio | ORIGINAL_REQUEST §R1 (Tactile Web Audio & mobile cues) | `e2e-voice-first-foundation.test.ts` | 5 | 5 | ✓ | ✓ | 10+ |
|
||||
| **F5** | Optimistic Input Queue & Zero UI Freeze | ORIGINAL_REQUEST §R2 (Non-blocking composer, optimistic FIFO) | `e2e-autonomous-agency-queues.test.ts` | 5 | 5 | ✓ | ✓ | 10+ |
|
||||
| **F6** | Mid-Step Tool Cancellation & Active Steering | ORIGINAL_REQUEST §R2 (Mid-execution abort, active steering) | `e2e-autonomous-agency-queues.test.ts` | 5 | 5 | ✓ | ✓ | 10+ |
|
||||
| **F7** | Outcome-Driven Action Queue State Machine | ORIGINAL_REQUEST §R2 (Dynamic action queue, outcomes) | `e2e-autonomous-agency-queues.test.ts` | 5 | 5 | ✓ | ✓ | 10+ |
|
||||
| **F8** | Quiet Background Routines & Delta Evaluation | ORIGINAL_REQUEST §R2 (on_change delta hash gating, no nag) | `e2e-autonomous-agency-queues.test.ts` | 5 | 5 | ✓ | ✓ | 10+ |
|
||||
| **F9** | Tactile Minimalist Step Execution | ORIGINAL_REQUEST §R3 (Strip chrome bloat, folding step) | `e2e-minimalist-tactile-ux.test.tsx` | 5 | 5 | ✓ | ✓ | 10+ |
|
||||
| **F10** | 30-Second Rapid Onboarding | ORIGINAL_REQUEST §R3 (1-click bootstrap, 3 action sparks) | `e2e-minimalist-tactile-ux.test.tsx` | 5 | 5 | ✓ | ✓ | 10+ |
|
||||
| **F11** | Monochrome Semantic Token Hygiene | ORIGINAL_REQUEST §R3 (@rakazo/ui-tokens, zero rogue hex) | `e2e-minimalist-tactile-ux.test.tsx` | 5 | 5 | ✓ | ✓ | 10+ |
|
||||
| **F12** | Living Canvas Progressive Disclosure | ORIGINAL_REQUEST §R3 (Dynamic canvas, preserve test IDs) | `e2e-minimalist-tactile-ux.test.tsx` | 5 | 5 | ✓ | ✓ | 10+ |
|
||||
| **T3** | Cross-Feature Interactions | Pairwise combinations across R1, R2, R3 | `e2e-cross-feature-workflows.test.ts` | - | - | 12 | - | 12 |
|
||||
| **T4** | Real-World Application Workflows | Realistic end-to-end operational scenarios | `e2e-cross-feature-workflows.test.ts` | - | - | - | 7 | 7 |
|
||||
| **Total** | **All Features F1–F12 across T1–T4** | **100% Comprehensive Coverage** | **4 Suites + 3 Fixtures** | **60** | **60** | **12** | **7** | **139** |
|
||||
|
||||
---
|
||||
|
||||
## 4. Test Tiers Breakdown
|
||||
|
||||
### Tier 1: Feature Coverage (60 Tests: 5 Tests × 12 Features)
|
||||
- **F1**: 80ms PCM16 frame chunking (1280 samples at 16kHz); VAD normalized RMS energy; <120ms cutoff threshold eliminating 850ms silence floor; monotonic timestamp increment; streaming client transcript & playback chunk callbacks.
|
||||
- **F2**: Continuous microphone capture with acoustic echo cancellation (`echoCancellation: true`); user speech energy detection while bot speaking triggers barge-in; immediate playback audio mute; backend abort propagation (`abort('barge_in')`); state transition from speaking to listening.
|
||||
- **F3**: Incremental clause segmentation in 4–6 word chunks; time-to-first-sound pipelining (<300ms TTFS target); stripping robotic invite prompts ("What would you like to do next?"); code fence description transformation per `speakable()`; natural punctuation boundary clause splits.
|
||||
- **F4**: Core tactile earcons (`listening-start`, `listening-stop`, `speaking-start`, `speaking-done`); Web Audio procedural oscillator profiles; master audio bus ducking (0.28 ducking ratio); cross-platform mobile earcon mapping; state machine audio cue triggers (`interact`, `attention`).
|
||||
- **F5**: Submitting message while another in-flight enqueues without UI freeze; temporary optimistic ID assignment (`temp-...`) and timestamp; composer text clearing immediately; Stop button unblocked and clickable; sequential FIFO drainage.
|
||||
- **F6**: Ingesting steering during active tool call dispatches mid-step cancellation signal; tool abort controller receives `abort('steering_redirection')`; aborted tool output discarded from history; agent pivots immediately to steering intent; steering message marked claimed.
|
||||
- **F7**: `DynamicActionQueue` initialization; multi-step prompt synthesis into `ActionQueueStep` items; queue advancement starts pending step with `startedAt`; completing step sets `completedAt` and advances; `cancelActive()` marks step cancelled.
|
||||
- **F8**: `RoutineNotificationPolicy` with `"on_change"` evaluates output delta hash; identical output hash suppresses notification (quiet run); distinct output hash triggers notification; policy `"always"` notifies unconditionally; policy `"never"` suppresses all notifications.
|
||||
- **F9**: Minimalist folding step button renders with `data-testid="run-steps"`; displays clean `# step` counter without progress track bloat; clicking fold button toggles `aria-expanded`; step tool name and summary rendered when expanded; keyboard Enter/Space toggles fold.
|
||||
- **F10**: 1-click bootstrap creates initial agent workspace without 11-step questionnaire; auto-detects system timezone via `Intl.DateTimeFormat`; auto-detects system language via `navigator.language`; surfaces 3 tactile starter sparks; selecting spark starts session (<30s value).
|
||||
- **F11**: Theme tokens definition from `@rakazo/ui-tokens`; zero hardcoded product hex colors; zero arbitrary Tailwind brackets `[var(--...)]`; accessible focus rings (`focus-visible:ring-ring`); status indicators use semantic tokens (`text-destructive`, `text-muted-foreground`).
|
||||
- **F12**: Living Canvas pane renders with `data-testid="side-panel"`; Mission Stream renders with `data-testid="mission-stream-pane"` and `data-testid="transcript"`; preserves critical test IDs (`computer-preview`, `dual-surface-divider`, `dual-surface-header-controls`); progressive disclosure on artifact creation; tab switching between Files, Sandbox, Browser.
|
||||
|
||||
### Tier 2: Boundary & Corner Cases (60 Tests: 5 Tests × 12 Features)
|
||||
- **F1**: Empty audio buffer (`Int16Array(0)`) handled with RMS 0 and no NaN errors; pure silence buffer maintains energy 0 and triggers silence cutoff without hang; clipping values clamped to Int16 limits (-32768 to 32767); out-of-order timestamp detection and jitter buffer tolerance; network drop triggers client reconnect and state preservation.
|
||||
- **F2**: Microphone permission denied handled with graceful error state; interruption when audio playback is already idle is safe no-op; micro-burst transients (<50ms noise pop) filtered by VAD debounce; rapid alternating speaker turns (ping-pong barge-in); concurrent interrupt and incoming playback chunk race condition resolves in favor of user interrupt.
|
||||
- **F3**: Single-word answers streamed without minimum character delay stalling; code-only block output sanitized without synthetic audio artifacts; emoji-only message stream strips emojis and does not stall TTS engine; micro-fragment token arrivals buffered into complete words; unexpected LLM stream termination cleanly finalizes active clause.
|
||||
- **F4**: Rapid consecutive sound cue triggers (<10ms) queue smoothly without audio glitches; muted/silent mode suppresses oscillator output while executing state transition; AudioContext suspended state auto-resumes on first user gesture; unknown sound cue name falls back gracefully without throwing exceptions; cross-platform volume normalization preserves peak headroom.
|
||||
- **F5**: Rapid typing burst (10 rapid inputs within 100ms) enqueues all 10 without dropping; bounded queue size enforces max pending limit with graceful error; network failure while message in flight triggers failed state with retry option without losing text; empty/whitespace-only messages rejected before enqueueing; duplicate messages submitted in rapid succession deduplicated if flagged.
|
||||
- **F6**: Race condition: tool finishes at the exact moment abort signal fires resolves cleanly; tool execution timeout triggers automated mid-step cancellation with `timeout` reason; multiple rapid contradictory steering messages resolves to most recent intent; tool that throws exception upon abort caught gracefully without crashing runtime; external system side-effect fence prevents state pollution on aborted tools.
|
||||
- **F7**: Synthesizing empty or meaningless input results in clean no-op; `advance()` called on an empty queue or completed queue is safe no-op; failed step status prevents automatic advance unless retry policy allows; step status state machine strictly validates allowable transitions; queue serialization and deserialization preserves complete step fidelity.
|
||||
- **F8**: First execution (`prevOutputHash === ''`) with `"on_change"` triggers initial notification; policy `"on_failure"` triggers notification only when routine status is `"failed"`; whitespace/formatting variations evaluated cleanly against normalized hash; high frequency routine executions do not spam notifications if output remains constant; routine execution failure self-heals without crashing scheduler.
|
||||
- **F9**: Empty steps list renders cleanly without error or broken layout; extreme step count (50+ tool calls) remains folded by default without layout overflow; failed step renders clean status badge without noisy error stacks unless expanded; absence of chrome bloat (removal of animated tracks, live timers `· 1:24`, uppercase callouts `AUFGABE`); rapid folding/unfolding toggles do not oscillate or corrupt DOM state.
|
||||
- **F10**: Missing or invalid timezone fallback safely resolves to UTC without throwing error; missing or invalid locale fallback safely resolves to `en`; re-entering onboarding while workspace already exists redirects to active space; starter spark with special characters sanitized safely; offline onboarding gracefully saves initial draft locally without blocking UI.
|
||||
- **F11**: Semantic contrast in monochrome mode preserves readable text tokens; disabled component states apply semantic opacity `disabled:opacity-50` rather than arbitrary grey; high contrast mode support: borders preserve visibility via semantic `border-border`; dynamic theme switching updates token class mappings deterministically; interactive hover states use semantic hover tokens (`hover:bg-accent`).
|
||||
- **F12**: Closing Living Canvas collapses aside pane without unmounting workspace state; rapid switching between canvas tabs maintains individual surface state; resizing viewport below `md` breakpoint gracefully transitions Dual-Surface to responsive layout; expanding viewport back above `md` restores Dual-Surface split without reset; concurrent artifact creation from multiple sub-agents surfaces items progressively without flicker.
|
||||
|
||||
### Tier 3: Cross-Feature Interactions (12 Tests: Pairwise Coverage)
|
||||
- **Pair 1**: Voice barge-in interrupt while action queue is actively running step (aborts active step, pauses execution, pivots queue).
|
||||
- **Pair 2**: Optimistic queue burst while streaming audio TTS is playing (synthesizes follow-ups without audio dropout).
|
||||
- **Pair 3**: Mid-step tool cancellation triggered via voice interruption event (voice audio energy spike propagates to in-flight tool abort controller).
|
||||
- **Pair 4**: Quiet background routine fires during active voice session (audio ducking remains unaffected; output unchanged suppresses chat/audio notification).
|
||||
- **Pair 5**: 30-second rapid onboarding directly initiates full-duplex voice stage (first-time user goes from 1-click bootstrap straight to hands-free voice dialogue).
|
||||
- **Pair 6**: Action queue synthesis dynamically surfaces Living Canvas for artifact inspection (multi-step queue opens canvas on artifact creation).
|
||||
- **Pair 7**: Tactile folding step reflects mid-step tool cancellation without UI glitch (shows `[cancelled]` step folded cleanly).
|
||||
- **Pair 8**: Voice streaming client reconnects after temporary network drop while action queue advances to next step.
|
||||
- **Pair 9**: Audio earcon triggers on optimistic queue drain and background delta detection (`interact` on drain, `attention` on delta).
|
||||
- **Pair 10**: Living Canvas orientation swap while action queue runs background steps (DOM flex order reverses while steps advance uninterrupted).
|
||||
- **Pair 11**: Voice interrupt resets incremental utterance clause pipelining (discards queued clauses immediately).
|
||||
- **Pair 12**: Delta routine evaluation suppresses notification while user is typing in optimistic composer (zero disruption to active composer focus).
|
||||
|
||||
### Tier 4: Real-World Application Scenarios (7 Comprehensive Workflows)
|
||||
- **Scenario 1: Ambient Hands-Free Meeting & Live Action Queue**: User dictates three multi-step action items in voice mode -> VAD frames audio -> parsed into Dynamic Action Queue -> steps advance deterministically -> earcon signals completion.
|
||||
- **Scenario 2: Conversational Barge-In & Mid-Step Tool Cancellation**: Agent executing long command -> user speaks barge-in redirect -> playback instantly muted -> in-flight tool aborted -> queue pivoted to new intent.
|
||||
- **Scenario 3: High-Frequency Multi-Prompt Burst & Optimistic Ordering**: User types 3 follow-up messages rapidly during elevated latency -> optimistic queue renders all 3 immediately with temporary IDs -> requests drain in order -> zero UI freeze.
|
||||
- **Scenario 4: Quiet Background Routine with Delta Detection & No Nagging**: Scheduled routine runs periodic cycles -> hash unchanged -> zero notifications -> on cycle with detected anomaly, delta triggers notification -> user alerted only when critical.
|
||||
- **Scenario 5: Rapid 30-Second Zero-Questionnaire Onboarding to Live Execution**: New user lands -> system auto-detects timezone and locale -> user clicks starter spark "Research competitor architecture" -> workspace boots instantly -> agent begins execution within 30 seconds.
|
||||
- **Scenario 6: Tactile Dual-Surface Progressive Disclosure & Step Folding Workflow**: User asks agent to inspect repo -> agent executes 12 tool steps -> steps fold into clean `# steps` button -> agent writes file -> Living Canvas smoothly unfolds without modal occlusion.
|
||||
- **Scenario 7: Network Loss & Reconnection Resilience during Full-Duplex Voice Session**: Voice stream active -> network packet loss occurs -> streaming client detects connection drop -> buffers outgoing frames -> reconnects -> flushes buffer and resumes conversation without data loss.
|
||||
|
||||
---
|
||||
|
||||
## 5. Artifact Index & Directory Layout
|
||||
|
||||
```
|
||||
apps/web/src/tests/
|
||||
├── e2e-voice-first-foundation.test.ts # 40 tests (Features F1-F4, Tiers 1-2)
|
||||
├── e2e-autonomous-agency-queues.test.ts # 40 tests (Features F5-F8, Tiers 1-2)
|
||||
├── e2e-minimalist-tactile-ux.test.tsx # 40 tests (Features F9-F12, Tiers 1-2)
|
||||
├── e2e-cross-feature-workflows.test.ts # 19 tests (Tier 3 Pairwise & Tier 4 Scenarios)
|
||||
└── fixtures/
|
||||
├── voice-contracts.ts # F1-F4 contracts & reference VAD/framing models
|
||||
├── agency-contracts.ts # F5-F8 contracts, Action Queue & Routine policy models
|
||||
└── ux-contracts.ts # F9-F12 contracts, environment detection & starter sparks
|
||||
```
|
||||
123
TEST_READY.md
Normal file
123
TEST_READY.md
Normal file
@ -0,0 +1,123 @@
|
||||
# TEST_READY: evOo Voice-First Autonomous Agent Operating System
|
||||
|
||||
## Status
|
||||
- **Test Suite Status**: READY
|
||||
- **Author**: `test_writer_r2_1` (Dual-Track E2E Testing Specialist)
|
||||
- **Timestamp**: 2026-10-12T13:05:00Z
|
||||
- **Target Milestones**: M1, M2, M3, M4, M5 (Features F1–F12 across Requirements R1, R2, R3)
|
||||
- **Test Framework**: Vitest 4.1.11 with `// @vitest-environment jsdom` & Pure React 19 harnesses
|
||||
|
||||
---
|
||||
|
||||
## 1. Authored Test Suites Summary
|
||||
|
||||
| # | Test File Path | Scope & Feature Focus | Tiers Covered | Total Test Cases | Passing Baseline |
|
||||
|---|----------------|-----------------------|:-------------:|:----------------:|:----------------:|
|
||||
| 1 | `apps/web/src/tests/e2e-voice-first-foundation.test.ts` | Streaming Audio Wire (<120ms framing, PCM16, VAD), Full-Duplex Audio & Barge-In Interruption, Incremental Utterance Streamer (<300ms TTFS, invite strip), Tactile Monochrome Earcons | Tier 1, Tier 2 (F1–F4) | 40 | ✅ 40 / 40 Passed |
|
||||
| 2 | `apps/web/src/tests/e2e-autonomous-agency-queues.test.ts` | Optimistic Input Queue (Zero UI Freeze, unblocked stop), Mid-Step Tool Cancellation & Active Steering, Dynamic Action Queue State Machine, Quiet Background Routines & Delta Evaluation (`on_change`) | Tier 1, Tier 2 (F5–F8) | 40 | ✅ 40 / 40 Passed |
|
||||
| 3 | `apps/web/src/tests/e2e-minimalist-tactile-ux.test.tsx` | Tactile Minimalist Step Execution (strip JobProgressCard bloat, folding `# step`), 30-Second Rapid Onboarding (zero questionnaires, 3 action sparks), Monochrome Token Hygiene (@rakazo/ui-tokens), Living Canvas Progressive Disclosure | Tier 1, Tier 2 (F9–F12) | 40 | ✅ 40 / 40 Passed |
|
||||
| 4 | `apps/web/src/tests/e2e-cross-feature-workflows.test.ts` | 12 Pairwise Cross-Feature Interactions & 7 Realistic End-to-End Workflows (Hands-free meeting, voice barge-in tool abort, high-frequency prompt burst, quiet background routines, 30s onboarding, progressive disclosure, voice network resilience) | Tier 3, Tier 4 | 19 | ✅ 19 / 19 Passed |
|
||||
| **Total** | **4 Comprehensive Test Suites + 3 Fixtures** | **Full Feature Inventory (F1 to F12) Coverage** | **Tiers 1–4** | **139 tests** | **✅ 139 / 139 (100% Pass)** |
|
||||
|
||||
---
|
||||
|
||||
## 2. Requirement & Feature Coverage Matrix
|
||||
|
||||
| Feature | Source Requirement | Primary Test File | Tiers | Pass / Fail |
|
||||
|---------|-------------------|-------------------|:-----:|:-----------:|
|
||||
| **F1: Streaming Audio Wire & Low-Latency VAD** | ORIGINAL_REQUEST §R1 | `e2e-voice-first-foundation.test.ts` | T1, T2, T3, T4 | ✅ PASS (10/10) |
|
||||
| **F2: Full-Duplex Audio & Barge-In Interruption** | ORIGINAL_REQUEST §R1 | `e2e-voice-first-foundation.test.ts` | T1, T2, T3, T4 | ✅ PASS (10/10) |
|
||||
| **F3: Incremental Utterance Streamer & Invite Strip** | ORIGINAL_REQUEST §R1 | `e2e-voice-first-foundation.test.ts` | T1, T2, T3, T4 | ✅ PASS (10/10) |
|
||||
| **F4: Cross-Platform Earcons & Mobile Audio** | ORIGINAL_REQUEST §R1 | `e2e-voice-first-foundation.test.ts` | T1, T2, T3, T4 | ✅ PASS (10/10) |
|
||||
| **F5: Optimistic Input Queue & Zero UI Freeze** | ORIGINAL_REQUEST §R2 | `e2e-autonomous-agency-queues.test.ts` | T1, T2, T3, T4 | ✅ PASS (10/10) |
|
||||
| **F6: Mid-Step Tool Cancellation & Active Steering** | ORIGINAL_REQUEST §R2 | `e2e-autonomous-agency-queues.test.ts` | T1, T2, T3, T4 | ✅ PASS (10/10) |
|
||||
| **F7: Outcome-Driven Action Queue State Machine** | ORIGINAL_REQUEST §R2 | `e2e-autonomous-agency-queues.test.ts` | T1, T2, T3, T4 | ✅ PASS (10/10) |
|
||||
| **F8: Quiet Background Routines & Delta Evaluation** | ORIGINAL_REQUEST §R2 | `e2e-autonomous-agency-queues.test.ts` | T1, T2, T3, T4 | ✅ PASS (10/10) |
|
||||
| **F9: Tactile Minimalist Step Execution** | ORIGINAL_REQUEST §R3 | `e2e-minimalist-tactile-ux.test.tsx` | T1, T2, T3, T4 | ✅ PASS (10/10) |
|
||||
| **F10: 30-Second Rapid Onboarding** | ORIGINAL_REQUEST §R3 | `e2e-minimalist-tactile-ux.test.tsx` | T1, T2, T3, T4 | ✅ PASS (10/10) |
|
||||
| **F11: Monochrome Semantic Token Hygiene** | ORIGINAL_REQUEST §R3 | `e2e-minimalist-tactile-ux.test.tsx` | T1, T2, T3, T4 | ✅ PASS (10/10) |
|
||||
| **F12: Living Canvas Progressive Disclosure** | ORIGINAL_REQUEST §R3 | `e2e-minimalist-tactile-ux.test.tsx` | T1, T2, T3, T4 | ✅ PASS (10/10) |
|
||||
| **T3: Pairwise Cross-Feature Interactions** | Cross-Module Interactions | `e2e-cross-feature-workflows.test.ts` | Tier 3 | ✅ PASS (12/12) |
|
||||
| **T4: Real-World Application Scenarios** | Operational Workflows | `e2e-cross-feature-workflows.test.ts` | Tier 4 | ✅ PASS (7/7) |
|
||||
|
||||
---
|
||||
|
||||
## 3. Tier Breakdown & Test Cases
|
||||
|
||||
### Tier 1: Feature Coverage (60 Tests)
|
||||
- **F1**: 80ms PCM16 frame chunking (1280 samples at 16kHz); VAD normalized RMS energy; <120ms cutoff threshold eliminating 850ms silence floor; monotonic timestamp increment; streaming client transcript & playback chunk callbacks.
|
||||
- **F2**: Continuous microphone capture with acoustic echo cancellation (`echoCancellation: true`); user speech energy detection while bot speaking triggers barge-in; immediate playback audio mute; backend abort propagation (`abort('barge_in')`); state transition from speaking to listening.
|
||||
- **F3**: Incremental clause segmentation in 4–6 word chunks; time-to-first-sound pipelining (<300ms TTFS target); stripping robotic invite prompts ("What would you like to do next?"); code fence description transformation per `speakable()`; natural punctuation boundary clause splits.
|
||||
- **F4**: Core tactile earcons (`listening-start`, `listening-stop`, `speaking-start`, `speaking-done`); Web Audio procedural oscillator profiles; master audio bus ducking (0.28 ducking ratio); cross-platform mobile earcon mapping; state machine audio cue triggers (`interact`, `attention`).
|
||||
- **F5**: Submitting message while another in-flight enqueues without UI freeze; temporary optimistic ID assignment (`temp-...`) and timestamp; composer text clearing immediately; Stop button unblocked and clickable; sequential FIFO drainage.
|
||||
- **F6**: Ingesting steering during active tool call dispatches mid-step cancellation signal; tool abort controller receives `abort('steering_redirection')`; aborted tool output discarded from history; agent pivots immediately to steering intent; steering message marked claimed.
|
||||
- **F7**: `DynamicActionQueue` initialization; multi-step prompt synthesis into `ActionQueueStep` items; queue advancement starts pending step with `startedAt`; completing step sets `completedAt` and advances; `cancelActive()` marks step cancelled.
|
||||
- **F8**: `RoutineNotificationPolicy` with `"on_change"` evaluates output delta hash; identical output hash suppresses notification (quiet run); distinct output hash triggers notification; policy `"always"` notifies unconditionally; policy `"never"` suppresses all notifications.
|
||||
- **F9**: Minimalist folding step button renders with `data-testid="run-steps"`; displays clean `# step` counter without progress track bloat; clicking fold button toggles `aria-expanded`; step tool name and summary rendered when expanded; keyboard Enter/Space toggles fold.
|
||||
- **F10**: 1-click bootstrap creates initial agent workspace without 11-step questionnaire; auto-detects system timezone via `Intl.DateTimeFormat`; auto-detects system language via `navigator.language`; surfaces 3 tactile starter sparks; selecting spark starts session (<30s value).
|
||||
- **F11**: Theme tokens definition from `@rakazo/ui-tokens`; zero hardcoded product hex colors; zero arbitrary Tailwind brackets `[var(--...)]`; accessible focus rings (`focus-visible:ring-ring`); status indicators use semantic tokens (`text-destructive`, `text-muted-foreground`).
|
||||
- **F12**: Living Canvas pane renders with `data-testid="side-panel"`; Mission Stream renders with `data-testid="mission-stream-pane"` and `data-testid="transcript"`; preserves critical test IDs (`computer-preview`, `dual-surface-divider`, `dual-surface-header-controls`); progressive disclosure on artifact creation; tab switching between Files, Sandbox, Browser.
|
||||
|
||||
### Tier 2: Boundary & Corner Cases (60 Tests)
|
||||
- **F1**: Empty audio buffer (`Int16Array(0)`) handled with RMS 0 and no NaN errors; pure silence buffer maintains energy 0 and triggers silence cutoff without hang; clipping values clamped to Int16 limits (-32768 to 32767); out-of-order timestamp detection and jitter buffer tolerance; network drop triggers client reconnect and state preservation.
|
||||
- **F2**: Microphone permission denied handled with graceful error state; interruption when audio playback is already idle is safe no-op; micro-burst transients (<50ms noise pop) filtered by VAD debounce; rapid alternating speaker turns (ping-pong barge-in); concurrent interrupt and incoming playback chunk race condition resolves in favor of user interrupt.
|
||||
- **F3**: Single-word answers streamed without minimum character delay stalling; code-only block output sanitized without synthetic audio artifacts; emoji-only message stream strips emojis and does not stall TTS engine; micro-fragment token arrivals buffered into complete words; unexpected LLM stream termination cleanly finalizes active clause.
|
||||
- **F4**: Rapid consecutive sound cue triggers (<10ms) queue smoothly without audio glitches; muted/silent mode suppresses oscillator output while executing state transition; AudioContext suspended state auto-resumes on first user gesture; unknown sound cue name falls back gracefully without throwing exceptions; cross-platform volume normalization preserves peak headroom.
|
||||
- **F5**: Rapid typing burst (10 rapid inputs within 100ms) enqueues all 10 without dropping; bounded queue size enforces max pending limit with graceful error; network failure while message in flight triggers failed state with retry option without losing text; empty/whitespace-only messages rejected before enqueueing; duplicate messages submitted in rapid succession deduplicated if flagged.
|
||||
- **F6**: Race condition: tool finishes at the exact moment abort signal fires resolves cleanly; tool execution timeout triggers automated mid-step cancellation with `timeout` reason; multiple rapid contradictory steering messages resolves to most recent intent; tool that throws exception upon abort caught gracefully without crashing runtime; external system side-effect fence prevents state pollution on aborted tools.
|
||||
- **F7**: Synthesizing empty or meaningless input results in clean no-op; `advance()` called on an empty queue or completed queue is safe no-op; failed step status prevents automatic advance unless retry policy allows; step status state machine strictly validates allowable transitions; queue serialization and deserialization preserves complete step fidelity.
|
||||
- **F8**: First execution (`prevOutputHash === ''`) with `"on_change"` triggers initial notification; policy `"on_failure"` triggers notification only when routine status is `"failed"`; whitespace/formatting variations evaluated cleanly against normalized hash; high frequency routine executions do not spam notifications if output remains constant; routine execution failure self-heals without crashing scheduler.
|
||||
- **F9**: Empty steps list renders cleanly without error or broken layout; extreme step count (50+ tool calls) remains folded by default without layout overflow; failed step renders clean status badge without noisy error stacks unless expanded; absence of chrome bloat (removal of animated tracks, live timers `· 1:24`, uppercase callouts `AUFGABE`); rapid folding/unfolding toggles do not oscillate or corrupt DOM state.
|
||||
- **F10**: Missing or invalid timezone fallback safely resolves to UTC without throwing error; missing or invalid locale fallback safely resolves to `en`; re-entering onboarding while workspace already exists redirects to active space; starter spark with special characters sanitized safely; offline onboarding gracefully saves initial draft locally without blocking UI.
|
||||
- **F11**: Semantic contrast in monochrome mode preserves readable text tokens; disabled component states apply semantic opacity `disabled:opacity-50` rather than arbitrary grey; high contrast mode support: borders preserve visibility via semantic `border-border`; dynamic theme switching updates token class mappings deterministically; interactive hover states use semantic hover tokens (`hover:bg-accent`).
|
||||
- **F12**: Closing Living Canvas collapses aside pane without unmounting workspace state; rapid switching between canvas tabs maintains individual surface state; resizing viewport below `md` breakpoint gracefully transitions Dual-Surface to responsive layout; expanding viewport back above `md` restores Dual-Surface split without reset; concurrent artifact creation from multiple sub-agents surfaces items progressively without flicker.
|
||||
|
||||
### Tier 3: Cross-Feature Interactions (12 Tests)
|
||||
- Pair 1: Voice barge-in interrupt while action queue is actively running step.
|
||||
- Pair 2: Optimistic queue burst while streaming audio TTS is playing.
|
||||
- Pair 3: Mid-step tool cancellation triggered via voice interruption event.
|
||||
- Pair 4: Quiet background routine fires during active voice session.
|
||||
- Pair 5: 30-second rapid onboarding directly initiates full-duplex voice stage.
|
||||
- Pair 6: Action queue synthesis dynamically surfaces Living Canvas for artifact inspection.
|
||||
- Pair 7: Tactile folding step reflects mid-step tool cancellation without UI glitch.
|
||||
- Pair 8: Voice streaming client reconnects after temporary network drop while action queue advances.
|
||||
- Pair 9: Audio earcon triggers on optimistic queue drain and background delta detection.
|
||||
- Pair 10: Living Canvas orientation swap while action queue runs background steps.
|
||||
- Pair 11: Voice interrupt resets incremental utterance clause pipelining.
|
||||
- Pair 12: Delta routine evaluation suppresses notification while user is typing in optimistic composer.
|
||||
|
||||
### Tier 4: Real-World Application Scenarios (7 Comprehensive Workflows)
|
||||
- Scenario 1: Ambient Hands-Free Meeting & Live Action Queue.
|
||||
- Scenario 2: Conversational Barge-In & Mid-Step Tool Cancellation.
|
||||
- Scenario 3: High-Frequency Multi-Prompt Burst & Optimistic Ordering.
|
||||
- Scenario 4: Quiet Background Routine with Delta Detection & No Nagging.
|
||||
- Scenario 5: Rapid 30-Second Zero-Questionnaire Onboarding to Live Execution.
|
||||
- Scenario 6: Tactile Dual-Surface Progressive Disclosure & Step Folding Workflow.
|
||||
- Scenario 7: Network Loss & Reconnection Resilience during Full-Duplex Voice Session.
|
||||
|
||||
---
|
||||
|
||||
## 4. Verification Method
|
||||
|
||||
Run the following command in the repository root to execute the complete E2E test suite:
|
||||
|
||||
```bash
|
||||
pnpm vitest run apps/web/src/tests/e2e-voice-first-foundation.test.ts apps/web/src/tests/e2e-autonomous-agency-queues.test.ts apps/web/src/tests/e2e-minimalist-tactile-ux.test.tsx apps/web/src/tests/e2e-cross-feature-workflows.test.ts
|
||||
```
|
||||
|
||||
Output:
|
||||
```
|
||||
Test Files 4 passed (4)
|
||||
Tests 139 passed (139)
|
||||
Duration ~3.0s
|
||||
```
|
||||
|
||||
TypeScript Check:
|
||||
```bash
|
||||
pnpm --filter @rakazo/web check
|
||||
```
|
||||
|
||||
Biome Lint & Format Check:
|
||||
```bash
|
||||
pnpm biome check apps/web/src/tests/e2e-*.ts* apps/web/src/tests/fixtures/*.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
|
||||
|
||||
|
||||
@ -26,9 +26,13 @@ function savedSkill(name: string, source = "user") {
|
||||
}
|
||||
|
||||
function setup(rows: ReturnType<typeof savedSkill>[] = []) {
|
||||
type Where = { id?: string; spaceId: string; userId: string; source?: string };
|
||||
type Where = { id?: string; spaceId?: string; userId: string; source?: string; name?: unknown };
|
||||
const matches = (row: ReturnType<typeof savedSkill>, where: Where) =>
|
||||
Object.entries(where).every(([key, value]) => row[key as keyof typeof row] === value);
|
||||
Object.entries(where).every(([key, value]) =>
|
||||
key === "name" && typeof value === "object" && value && "equals" in value
|
||||
? row.name.toLowerCase() === String(value.equals).toLowerCase()
|
||||
: key === "NOT" || row[key as keyof typeof row] === value,
|
||||
);
|
||||
const agentSkill = {
|
||||
findMany: vi.fn(async ({ where }: { where: Where }) =>
|
||||
rows.filter((row) => matches(row, where)),
|
||||
@ -55,15 +59,19 @@ function setup(rows: ReturnType<typeof savedSkill>[] = []) {
|
||||
};
|
||||
}
|
||||
|
||||
/** Only the entries competing for the name "Interrogate"; other builtins are irrelevant here. */
|
||||
const interrogate = <T extends { name: string }>(skills: T[]) =>
|
||||
skills.filter((skill) => skill.name.trim().toLowerCase() === "interrogate");
|
||||
|
||||
describe("built-in skill precedence in the API", () => {
|
||||
it.each(["interrogate", " Interrogate "])(
|
||||
"keeps %j listed, readable and mutable",
|
||||
async (name) => {
|
||||
const { service } = setup([savedSkill(name)]);
|
||||
await expect(service.list(actor)).resolves.toEqual([
|
||||
expect(interrogate(await service.list(actor))).toEqual([
|
||||
expect.objectContaining({ id: "saved-1", name, readOnly: false }),
|
||||
]);
|
||||
await expect(service.listWithContent(actor)).resolves.toEqual([
|
||||
expect(interrogate(await service.listWithContent(actor))).toEqual([
|
||||
expect.objectContaining({ id: "saved-1", content: expect.stringContaining("Saved steps") }),
|
||||
]);
|
||||
await expect(service.get(actor, { name: " INTERROGATE " })).resolves.toMatchObject({
|
||||
@ -97,7 +105,23 @@ describe("built-in skill precedence in the API", () => {
|
||||
expect(agentSkill.deleteMany).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each([{ spaceId: "other-space" }, { userId: "other-user" }])(
|
||||
it("lists the user's skills from other spaces too, the current space winning a clash", async () => {
|
||||
const { service } = setup([
|
||||
{ ...savedSkill("Launch"), id: "home-1", spaceId: "home" },
|
||||
{ ...savedSkill("launch"), id: "here-1" },
|
||||
{ ...savedSkill("Weekly review"), id: "home-2", spaceId: "home" },
|
||||
]);
|
||||
const own = (await service.list(actor)).filter((skill) => skill.source === "user");
|
||||
expect(own.map((skill) => skill.id).sort()).toEqual(["here-1", "home-2"]);
|
||||
await expect(service.get(actor, { name: "Weekly review" })).resolves.toMatchObject({
|
||||
id: "home-2",
|
||||
});
|
||||
await expect(
|
||||
service.create(actor, { name: "weekly REVIEW", description: "x" }),
|
||||
).rejects.toThrow("already exists");
|
||||
});
|
||||
|
||||
it.each([{ userId: "other-user" }])(
|
||||
"does not let foreign skills shadow the builtin: %j",
|
||||
async (foreignOwner) => {
|
||||
const { service } = setup([{ ...savedSkill(" Interrogate "), ...foreignOwner }]);
|
||||
@ -105,7 +129,7 @@ describe("built-in skill precedence in the API", () => {
|
||||
id: "builtin:Interrogate",
|
||||
readOnly: true,
|
||||
});
|
||||
await expect(service.list(actor)).resolves.toEqual([
|
||||
expect(interrogate(await service.list(actor))).toEqual([
|
||||
expect.objectContaining({ id: "builtin:Interrogate" }),
|
||||
]);
|
||||
await expect(service.get(actor, { skillId: "saved-1" })).rejects.toThrow();
|
||||
|
||||
@ -7,6 +7,7 @@ import {
|
||||
isSkillReadOnly,
|
||||
mergeBuiltinSkills,
|
||||
parseSkillMd,
|
||||
preferSpaceSkills,
|
||||
type SkillSource,
|
||||
} from "@rakazo/core";
|
||||
import { IsolationError, type PrismaClient } from "@rakazo/db";
|
||||
@ -121,11 +122,7 @@ export function resolveSkillContent(input: {
|
||||
export function createAgentSkillsService(prisma: PrismaClient) {
|
||||
async function owned(actor: Actor, skillId: string) {
|
||||
const row = await prisma.agentSkill.findFirst({
|
||||
where: {
|
||||
id: skillId,
|
||||
spaceId: actor.spaceId,
|
||||
userId: actor.userId,
|
||||
},
|
||||
where: { id: skillId, userId: actor.userId },
|
||||
});
|
||||
if (!row) throw new IsolationError();
|
||||
return row;
|
||||
@ -134,20 +131,24 @@ export function createAgentSkillsService(prisma: PrismaClient) {
|
||||
return {
|
||||
async list(actor: Actor): Promise<Omit<AgentSkill, "content">[]> {
|
||||
const rows = await prisma.agentSkill.findMany({
|
||||
where: { spaceId: actor.spaceId, userId: actor.userId },
|
||||
where: { userId: actor.userId },
|
||||
orderBy: [{ name: "asc" }, { id: "asc" }],
|
||||
});
|
||||
return mergeBuiltinSkills(builtinCatalog(), rows.map(mapAgentSkill)).map(
|
||||
({ content: _content, ...entry }) => entry,
|
||||
);
|
||||
return mergeBuiltinSkills(
|
||||
builtinCatalog(),
|
||||
preferSpaceSkills(rows, actor.spaceId).map(mapAgentSkill),
|
||||
).map(({ content: _content, ...entry }) => entry);
|
||||
},
|
||||
|
||||
async listWithContent(actor: Actor): Promise<AgentSkill[]> {
|
||||
const rows = await prisma.agentSkill.findMany({
|
||||
where: { spaceId: actor.spaceId, userId: actor.userId },
|
||||
where: { userId: actor.userId },
|
||||
orderBy: [{ name: "asc" }, { id: "asc" }],
|
||||
});
|
||||
return mergeBuiltinSkills(builtinCatalog(), rows.map(mapAgentSkill));
|
||||
return mergeBuiltinSkills(
|
||||
builtinCatalog(),
|
||||
preferSpaceSkills(rows, actor.spaceId).map(mapAgentSkill),
|
||||
);
|
||||
},
|
||||
|
||||
async get(actor: Actor, input: { skillId?: string; name?: string }): Promise<AgentSkill> {
|
||||
@ -161,13 +162,10 @@ export function createAgentSkillsService(prisma: PrismaClient) {
|
||||
}
|
||||
const name = input.name?.trim() ?? "";
|
||||
const rows = await prisma.agentSkill.findMany({
|
||||
where: {
|
||||
spaceId: actor.spaceId,
|
||||
userId: actor.userId,
|
||||
},
|
||||
where: { userId: actor.userId },
|
||||
orderBy: [{ name: "asc" }, { id: "asc" }],
|
||||
});
|
||||
const row = findSkillByName(rows, name);
|
||||
const row = findSkillByName(preferSpaceSkills(rows, actor.spaceId), name);
|
||||
if (row) return mapAgentSkill(row);
|
||||
const builtin = findSkillByName(builtinCatalog(), name);
|
||||
if (!builtin) throw new IsolationError();
|
||||
@ -181,7 +179,6 @@ export function createAgentSkillsService(prisma: PrismaClient) {
|
||||
const resolved = resolveSkillContent(input);
|
||||
const clash = await prisma.agentSkill.findFirst({
|
||||
where: {
|
||||
spaceId: actor.spaceId,
|
||||
userId: actor.userId,
|
||||
name: { equals: resolved.name, mode: "insensitive" },
|
||||
},
|
||||
@ -234,7 +231,6 @@ export function createAgentSkillsService(prisma: PrismaClient) {
|
||||
if (!findSkillByName([existing], resolved.name)) {
|
||||
const clash = await prisma.agentSkill.findFirst({
|
||||
where: {
|
||||
spaceId: actor.spaceId,
|
||||
userId: actor.userId,
|
||||
name: { equals: resolved.name, mode: "insensitive" },
|
||||
NOT: { id: existing.id },
|
||||
@ -250,12 +246,7 @@ export function createAgentSkillsService(prisma: PrismaClient) {
|
||||
// Mutate only owner-scoped user rows (never builtin/plugin), even if source was tampered.
|
||||
try {
|
||||
const updated = await prisma.agentSkill.updateMany({
|
||||
where: {
|
||||
id: existing.id,
|
||||
spaceId: actor.spaceId,
|
||||
userId: actor.userId,
|
||||
source: "user",
|
||||
},
|
||||
where: { id: existing.id, userId: actor.userId, source: "user" },
|
||||
data: {
|
||||
name: resolved.name,
|
||||
description: resolved.description,
|
||||
@ -275,11 +266,7 @@ export function createAgentSkillsService(prisma: PrismaClient) {
|
||||
throw error;
|
||||
}
|
||||
const row = await prisma.agentSkill.findFirst({
|
||||
where: {
|
||||
id: existing.id,
|
||||
spaceId: actor.spaceId,
|
||||
userId: actor.userId,
|
||||
},
|
||||
where: { id: existing.id, userId: actor.userId },
|
||||
});
|
||||
if (!row) throw new IsolationError();
|
||||
return mapAgentSkill(row);
|
||||
@ -291,12 +278,7 @@ export function createAgentSkillsService(prisma: PrismaClient) {
|
||||
throw new ORPCError("BAD_REQUEST", { message: "Builtin and plugin skills are read-only." });
|
||||
}
|
||||
const deleted = await prisma.agentSkill.deleteMany({
|
||||
where: {
|
||||
id: existing.id,
|
||||
spaceId: actor.spaceId,
|
||||
userId: actor.userId,
|
||||
source: "user",
|
||||
},
|
||||
where: { id: existing.id, userId: actor.userId, source: "user" },
|
||||
});
|
||||
if (deleted.count !== 1) throw new IsolationError();
|
||||
return { ok: true };
|
||||
|
||||
@ -20,8 +20,8 @@ function setup() {
|
||||
secret: { findMany: vi.fn(async () => []) },
|
||||
bot: { findMany: vi.fn(async () => []) },
|
||||
spaceVoicePreference: {
|
||||
findFirst: vi.fn(async () => ({ credential: { provider: "openai" }, voiceId: "alloy" })),
|
||||
findMany: vi.fn(async () => [{ credential: { provider: "openai" } }]),
|
||||
findFirst: vi.fn(async () => ({ credential: { provider: "kokoro" }, voiceId: "af_heart" })),
|
||||
findMany: vi.fn(async () => [{ credential: { provider: "kokoro" } }]),
|
||||
},
|
||||
spaceMemoryConfig: {
|
||||
findUnique: vi.fn(async () => null),
|
||||
@ -104,12 +104,12 @@ describe("consent grants", () => {
|
||||
it("discloses only the selected voice provider before a voice action", async () => {
|
||||
const { deps, actor } = setup();
|
||||
vi.mocked(deps.prisma.spaceVoicePreference.findMany).mockResolvedValue([
|
||||
{ credential: { provider: "openai" } },
|
||||
{ credential: { provider: "elevenlabs" } },
|
||||
{ credential: { provider: "kokoro" } },
|
||||
{ credential: { provider: "scripted" } },
|
||||
] as never);
|
||||
const status = await aiConsentStatus(deps, actor, { uses: ["voice"] });
|
||||
expect(status.recipients).toHaveLength(1);
|
||||
expect(status.recipients[0]?.name).toBe("OpenAI");
|
||||
expect(status.recipients[0]?.name).toBe("Local voice");
|
||||
expect(deps.prisma.spaceVoicePreference.findMany).not.toHaveBeenCalled();
|
||||
});
|
||||
it("discloses memory provider details to space owners", async () => {
|
||||
@ -155,7 +155,12 @@ describe("consent grants", () => {
|
||||
]);
|
||||
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);
|
||||
// The local voice cannot transcribe, so dictation also goes to the transcription server.
|
||||
expect(
|
||||
(await aiConsentStatus(deps, actor, { uses: ["voice"] })).recipients.map(
|
||||
(recipient) => recipient.name,
|
||||
),
|
||||
).toEqual(["Local voice", "Transcription (http://127.0.0.1:8080)"]);
|
||||
});
|
||||
it("discloses the transcription server for dictation when the voice provider cannot transcribe", async () => {
|
||||
const { deps, actor } = setup();
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { ORPCError, onError } from "@orpc/server";
|
||||
import { ORPCError, onError, ValidationError } from "@orpc/server";
|
||||
import { RPCHandler } from "@orpc/server/fetch";
|
||||
import type {
|
||||
AgentRuntime,
|
||||
@ -29,12 +29,14 @@ import {
|
||||
createMediaRuntime,
|
||||
createMessagingContextLoader,
|
||||
createMessagingTeamChatSender,
|
||||
createPostgresReconciliationLeadership,
|
||||
createProfileConsolidationScheduler,
|
||||
createRunExecutor,
|
||||
createRunSandbox,
|
||||
createRunSecretWriter,
|
||||
createWebProvider,
|
||||
destroyBot,
|
||||
destroySpaceSandboxes,
|
||||
EmailEmulator,
|
||||
EncryptedSecretStore,
|
||||
ExpoPushSender,
|
||||
@ -83,6 +85,7 @@ import {
|
||||
parsePositiveInteger,
|
||||
provisionMessagingIdentity,
|
||||
requireMembership,
|
||||
spaceIdsForSandboxTeardownAfterAccountDelete,
|
||||
} from "@rakazo/db";
|
||||
import type { Logger } from "@rakazo/logging";
|
||||
import {
|
||||
@ -118,6 +121,7 @@ import {
|
||||
TEAM_CHAT_STARTUP_SHUTDOWN_MS,
|
||||
} from "./team-chat-startup.js";
|
||||
import { mountVoiceHttpRoutes } from "./voice.js";
|
||||
import { isLoopbackHost, isPrivateOrLocalHost, requestWebOrigin } from "./web-origin.js";
|
||||
import { mountWebhookHttpRoutes } from "./webhook.js";
|
||||
|
||||
export interface AppHandles {
|
||||
@ -357,30 +361,45 @@ export async function createApp(
|
||||
"https://172.*:*",
|
||||
"http://*.local:*",
|
||||
"https://*.local:*",
|
||||
...(process.env.EXTRA_TRUSTED_ORIGINS?.split(",").map((s) => s.trim()) ?? []),
|
||||
...(env.extraTrustedOrigins ?? []),
|
||||
],
|
||||
beforeDeleteUser: async (userId) => {
|
||||
const bots = await prisma.bot.findMany({
|
||||
where: { userId },
|
||||
select: { id: true, userId: true, spaceId: true, name: true, archivedAt: true },
|
||||
});
|
||||
const lifecycleDeps = {
|
||||
prisma,
|
||||
sandbox,
|
||||
home,
|
||||
jobs,
|
||||
artifacts,
|
||||
dataDir: env.dataDir,
|
||||
};
|
||||
const deleteContext = {
|
||||
operationId: `account-delete:${userId}`,
|
||||
traceId: `account-delete:${userId}`,
|
||||
userId,
|
||||
signal: new AbortController().signal,
|
||||
};
|
||||
await Promise.all(
|
||||
bots.map((bot) =>
|
||||
destroyBot(
|
||||
{ prisma, sandbox, home, jobs, artifacts, dataDir: env.dataDir },
|
||||
lifecycleDeps,
|
||||
bot,
|
||||
{
|
||||
operationId: `account-delete:${userId}`,
|
||||
traceId: `account-delete:${userId}`,
|
||||
spaceId: bot.spaceId,
|
||||
userId,
|
||||
botId: bot.id,
|
||||
signal: new AbortController().signal,
|
||||
},
|
||||
{ ...deleteContext, spaceId: bot.spaceId, botId: bot.id },
|
||||
{ deleteMemories: true },
|
||||
),
|
||||
),
|
||||
);
|
||||
const candidateSpaceIds = [...new Set(bots.map((bot) => bot.spaceId))];
|
||||
const spaceIds = await spaceIdsForSandboxTeardownAfterAccountDelete(prisma, {
|
||||
userId,
|
||||
spaceIds: candidateSpaceIds,
|
||||
});
|
||||
for (const spaceId of spaceIds) {
|
||||
await destroySpaceSandboxes(lifecycleDeps, { spaceId }, { ...deleteContext, spaceId });
|
||||
}
|
||||
await removePiUserSessions(env.dataDir, userId);
|
||||
},
|
||||
});
|
||||
@ -456,26 +475,30 @@ export async function createApp(
|
||||
cloudAgent,
|
||||
media,
|
||||
});
|
||||
let graphileWorkerHost: GraphileJobWorkerHost | undefined;
|
||||
if (inMemoryJobs) {
|
||||
await inMemoryJobs.start(jobHandlers);
|
||||
} else if (jobPool) {
|
||||
const graphileWorkerHost = new GraphileJobWorkerHost(jobPool, {
|
||||
concurrency: 4,
|
||||
graphileWorkerHost = new GraphileJobWorkerHost(jobPool, {
|
||||
concurrency: parsePositiveInteger(process.env.GRAPHILE_WORKER_CONCURRENCY, 4),
|
||||
pollInterval: 500,
|
||||
});
|
||||
await graphileWorkerHost.start(jobHandlers);
|
||||
}
|
||||
const reconciler = inMemoryJobs
|
||||
? createJobReconciler({
|
||||
prisma,
|
||||
jobs,
|
||||
reconcileCloudAgents: () => reconcileCloudAgents({ prisma, jobs, cloudAgent }),
|
||||
reconcileComputerUpdates: () => reconcileComputerUpdates({ prisma, jobs }),
|
||||
reconcileProfileConsolidation: createProfileConsolidationScheduler({ prisma, jobs }),
|
||||
reconcileDelegatedTasks: () => reconcileDelegatedTasks({ prisma, jobs, events }),
|
||||
reconcileMediaJobs: () => media.reconcile(),
|
||||
})
|
||||
: undefined;
|
||||
const reconciler =
|
||||
inMemoryJobs || !prismaOverride
|
||||
? createJobReconciler({
|
||||
prisma,
|
||||
jobs,
|
||||
events,
|
||||
leadership: jobPool ? createPostgresReconciliationLeadership(jobPool) : undefined,
|
||||
reconcileCloudAgents: () => reconcileCloudAgents({ prisma, jobs, cloudAgent }),
|
||||
reconcileComputerUpdates: () => reconcileComputerUpdates({ prisma, jobs }),
|
||||
reconcileProfileConsolidation: createProfileConsolidationScheduler({ prisma, jobs }),
|
||||
reconcileDelegatedTasks: () => reconcileDelegatedTasks({ prisma, jobs, events }),
|
||||
reconcileMediaJobs: () => media.reconcile(),
|
||||
})
|
||||
: undefined;
|
||||
reconciler?.start();
|
||||
|
||||
const router = createRouter({
|
||||
@ -590,7 +613,12 @@ export async function createApp(
|
||||
}
|
||||
const { matched, response } = await rpc.handle(c.req.raw, {
|
||||
prefix: "/rpc",
|
||||
context: { actor, signal: c.req.raw.signal, userAgent: c.req.header("user-agent") },
|
||||
context: {
|
||||
actor,
|
||||
signal: c.req.raw.signal,
|
||||
userAgent: c.req.header("user-agent"),
|
||||
webOrigin: requestWebOrigin(c.req.header("origin"), env.webOrigin, env.extraTrustedOrigins),
|
||||
},
|
||||
});
|
||||
if (matched) return c.newResponse(response.body, response);
|
||||
await next();
|
||||
@ -983,6 +1011,7 @@ export async function createApp(
|
||||
await teamChatBridge?.stop();
|
||||
await email?.drain?.();
|
||||
await reconciler?.stop();
|
||||
await graphileWorkerHost?.stop();
|
||||
await jobs.close();
|
||||
await realtime.close();
|
||||
await connector.stop();
|
||||
@ -998,6 +1027,7 @@ export async function createApp(
|
||||
function isTrustedOrigin(origin: string, env: AppEnv) {
|
||||
if (!origin) return true;
|
||||
if (origin === env.webOrigin || origin === env.apiUrl || origin === env.authUrl) return true;
|
||||
if (env.extraTrustedOrigins?.includes(origin)) return true;
|
||||
if (origin.startsWith("rakazo://") || origin.startsWith("exp://")) return true;
|
||||
try {
|
||||
const host = new URL(origin).hostname;
|
||||
@ -1007,20 +1037,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");
|
||||
@ -1039,7 +1055,18 @@ function sessionHeaders(request: Request) {
|
||||
* "fetch failed" and keep the host and errno one level down.
|
||||
*/
|
||||
export function logUnexpectedRpcError(error: unknown, path: readonly string[]): void {
|
||||
if (error instanceof ORPCError) return;
|
||||
const where = `rpc ${path.join("/")} failed`;
|
||||
if (error instanceof ORPCError) {
|
||||
// A handler result that breaks its output contract is a server bug, not a chosen error.
|
||||
if (error.code !== "INTERNAL_SERVER_ERROR" || !(error.cause instanceof ValidationError)) return;
|
||||
const issues = error.cause.issues.map((issue) => ({
|
||||
path: (issue.path ?? [])
|
||||
.map((segment) => (typeof segment === "object" ? segment.key : segment))
|
||||
.join("."),
|
||||
problem: issue.message,
|
||||
}));
|
||||
getLogger().error(where, error, { issues });
|
||||
return;
|
||||
}
|
||||
getLogger().error(where, error);
|
||||
}
|
||||
|
||||
@ -175,7 +175,7 @@ describe("upload timeouts", () => {
|
||||
|
||||
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.requestTimeout).toBe(2 * 60 * 60 * 1000);
|
||||
expect(server.headersTimeout).toBe(createServer().headersTimeout);
|
||||
});
|
||||
|
||||
|
||||
@ -1,8 +1,8 @@
|
||||
import type { AdapterContext, ArtifactByteRange, ArtifactStore } from "@rakazo/adapter-kit";
|
||||
import { ArtifactTooLargeError } from "@rakazo/adapter-kit";
|
||||
import { artifactMaxBytes, storeArtifactStream } from "@rakazo/adapters";
|
||||
import { storeArtifactStream } from "@rakazo/adapters";
|
||||
import type { Actor } from "@rakazo/contracts";
|
||||
import { attachmentKindOf, isAllowedAttachmentMimeType } from "@rakazo/contracts";
|
||||
import { artifactMaxBytes, 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";
|
||||
@ -11,11 +11,11 @@ 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.
|
||||
* Node's default 5-minute requestTimeout cuts off a large upload on a slow uplink; 2 hours fits a
|
||||
* 5 GiB repository ZIP at about 6 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 };
|
||||
export const API_SERVER_OPTIONS = { requestTimeout: 2 * 60 * 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;
|
||||
|
||||
|
||||
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,
|
||||
}));
|
||||
}
|
||||
@ -20,6 +20,7 @@ export interface AppEnv {
|
||||
authSecret: string;
|
||||
authUrl: string;
|
||||
webOrigin: string;
|
||||
extraTrustedOrigins: string[];
|
||||
privacyPolicyUrl?: string;
|
||||
apiUrl: string;
|
||||
apiHost: string;
|
||||
@ -109,6 +110,10 @@ export function loadEnv(source: NodeJS.ProcessEnv = process.env): AppEnv {
|
||||
authSecret,
|
||||
authUrl: source.BETTER_AUTH_URL ?? source.WEB_ORIGIN ?? "http://127.0.0.1:5173",
|
||||
webOrigin: source.WEB_ORIGIN ?? "http://127.0.0.1:5173",
|
||||
extraTrustedOrigins: (source.EXTRA_TRUSTED_ORIGINS ?? "")
|
||||
.split(",")
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean),
|
||||
privacyPolicyUrl: optional(source.PRIVACY_POLICY_URL),
|
||||
apiUrl: source.API_URL ?? "http://127.0.0.1:3100",
|
||||
apiHost: source.API_HOST ?? "127.0.0.1",
|
||||
|
||||
@ -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";
|
||||
|
||||
@ -291,4 +293,45 @@ describe("buildInboxItems", () => {
|
||||
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,5 +1,5 @@
|
||||
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";
|
||||
@ -10,6 +10,7 @@ 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"]>;
|
||||
@ -42,6 +43,18 @@ export interface InboxUnreadThreadRow {
|
||||
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 : [];
|
||||
@ -98,7 +111,8 @@ 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. Items from another space name it.
|
||||
*/
|
||||
@ -107,6 +121,8 @@ export function buildInboxItems(input: {
|
||||
/** 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[] {
|
||||
@ -156,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) {
|
||||
@ -180,13 +213,19 @@ export function buildInboxItems(input: {
|
||||
const botSelect = { id: true, name: true, color: true } as const;
|
||||
|
||||
/**
|
||||
* The other spaces whose items the inbox shows: every space the user belongs to, except spaces
|
||||
* being deleted.
|
||||
* The other spaces whose items the inbox shows. The home space's main conversation collects every
|
||||
* space the user belongs to, except spaces being deleted; a project's main conversation keeps to
|
||||
* its own project.
|
||||
*/
|
||||
async function otherInboxSpaces(
|
||||
prisma: PrismaClient,
|
||||
actor: Actor,
|
||||
): Promise<Map<string, InboxSpace>> {
|
||||
const current = await prisma.space.findUnique({
|
||||
where: { id: actor.spaceId },
|
||||
select: { isDefault: true },
|
||||
});
|
||||
if (!current?.isDefault) return new Map();
|
||||
const memberships = await prisma.spaceMember.findMany({
|
||||
where: { userId: actor.userId, spaceId: { not: actor.spaceId }, space: { deletingAt: null } },
|
||||
select: { space: { select: { id: true, name: true } } },
|
||||
@ -203,7 +242,12 @@ export async function listInbox(prisma: PrismaClient, actor: Actor): Promise<Inb
|
||||
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: { in: spaceIds },
|
||||
@ -219,13 +263,7 @@ export async function listInbox(prisma: PrismaClient, actor: Actor): Promise<Inb
|
||||
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,
|
||||
@ -252,7 +290,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
|
||||
? []
|
||||
@ -269,5 +354,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, otherSpaces });
|
||||
return buildInboxItems({ runs, messages, unreadThreads, suggestions, otherSpaces });
|
||||
}
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import { ORPCError } from "@orpc/server";
|
||||
import { ORPCError, ValidationError } from "@orpc/server";
|
||||
import { createLogger, createTestSink, installLogger } from "@rakazo/logging";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { logUnexpectedRpcError } from "./app.js";
|
||||
@ -20,6 +20,28 @@ describe("logUnexpectedRpcError", () => {
|
||||
expect(sink.events).toEqual([]);
|
||||
});
|
||||
|
||||
it("names the procedure and the fields when a result breaks its output contract", () => {
|
||||
const sink = createTestSink();
|
||||
installLogger(createLogger({ service: "rakazo-api", sinks: [sink] }));
|
||||
const error = new ORPCError("INTERNAL_SERVER_ERROR", {
|
||||
message: "Output validation failed",
|
||||
cause: new ValidationError({
|
||||
message: "Output validation failed",
|
||||
issues: [{ message: "Expected string", path: ["items", 0, { key: "title" }] }],
|
||||
data: { items: [{ title: null, body: "private" }] },
|
||||
}),
|
||||
});
|
||||
|
||||
logUnexpectedRpcError(error, ["inbox", "list"]);
|
||||
|
||||
expect(sink.events).toHaveLength(1);
|
||||
expect(sink.events[0]).toMatchObject({
|
||||
message: "rpc inbox/list failed",
|
||||
issues: [{ path: "items.0.title", problem: "Expected string" }],
|
||||
});
|
||||
expect(JSON.stringify(sink.events[0])).not.toContain("private");
|
||||
});
|
||||
|
||||
it("names the procedure and every cause behind an opaque failure", () => {
|
||||
const sink = createTestSink();
|
||||
installLogger(createLogger({ service: "rakazo-api", sinks: [sink] }));
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
import type { MediaCapabilities } from "@rakazo/adapter-kit";
|
||||
import type { Actor } from "@rakazo/contracts";
|
||||
import { MediaOperationSchema } from "@rakazo/contracts";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { MediaRouteDeps } from "./media-jobs.js";
|
||||
import { getMediaCapabilities } from "./media-jobs.js";
|
||||
@ -28,7 +29,7 @@ describe("media capabilities", () => {
|
||||
|
||||
it("reports nothing available without a media runtime", async () => {
|
||||
const capabilities = await getMediaCapabilities({} as MediaRouteDeps, actor);
|
||||
expect(Object.values(capabilities)).toHaveLength(6);
|
||||
expect(Object.keys(capabilities)).toEqual(MediaOperationSchema.options);
|
||||
expect(Object.values(capabilities).every((value) => value === false)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
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);
|
||||
}
|
||||
@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
closingNote,
|
||||
focusCard,
|
||||
focusFactValue,
|
||||
intakeContext,
|
||||
intakeStep,
|
||||
nextIntakeStep,
|
||||
@ -14,6 +15,13 @@ const plain = (text: string) => text.replace(/\s/g, " ");
|
||||
const off = { morning: null, evening: null, weekly: null };
|
||||
|
||||
describe("onboarding steps", () => {
|
||||
it("writes focus facts in the user's locale", () => {
|
||||
expect(focusFactValue("day", "en-US")).toBe("Wants help with day-to-day work first");
|
||||
expect(focusFactValue("day", "de-DE")).toBe(
|
||||
"Möchte zuerst bei der täglichen Arbeit unterstützt werden",
|
||||
);
|
||||
});
|
||||
|
||||
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");
|
||||
|
||||
@ -43,13 +43,39 @@ interface IntakeStep {
|
||||
save: (effects: IntakeEffects, answer: IntakeAnswer) => Promise<void>;
|
||||
}
|
||||
|
||||
export type FocusOptionId = "day" | "inbox" | "research" | "crew";
|
||||
|
||||
export interface FocusOption {
|
||||
id: string;
|
||||
id: FocusOptionId;
|
||||
letter: string;
|
||||
label: string;
|
||||
apps: string[];
|
||||
/** Stored as a `goals` fact, so it stays a plain English statement. */
|
||||
fact: string;
|
||||
}
|
||||
|
||||
const FOCUS_FACTS = {
|
||||
en: {
|
||||
day: "Wants help with day-to-day work first",
|
||||
inbox: "Wants help with inbox and email first",
|
||||
research: "Wants help with research and writing first",
|
||||
crew: "Wants a coordinated crew of specialist bots",
|
||||
},
|
||||
de: {
|
||||
day: "Möchte zuerst bei der täglichen Arbeit unterstützt werden",
|
||||
inbox: "Möchte zuerst bei Posteingang und E-Mail unterstützt werden",
|
||||
research: "Möchte zuerst bei Recherche und Schreiben unterstützt werden",
|
||||
crew: "Möchte eine koordinierte Crew aus Spezialisten-Bots",
|
||||
},
|
||||
} as const satisfies Record<string, Record<FocusOptionId, string>>;
|
||||
|
||||
/** Profile fact text for a focus answer, in the user's locale (defaults to English). */
|
||||
function isFocusOptionId(id: string): id is FocusOptionId {
|
||||
return FOCUS_OPTIONS.some((option) => option.id === id);
|
||||
}
|
||||
|
||||
export function focusFactValue(focusId: string, locale: string): string {
|
||||
if (!isFocusOptionId(focusId)) return focusId;
|
||||
const key: keyof typeof FOCUS_FACTS = locale.toLowerCase().startsWith("de") ? "de" : "en";
|
||||
return FOCUS_FACTS[key][focusId];
|
||||
}
|
||||
|
||||
export const FOCUS_OPTIONS: FocusOption[] = [
|
||||
@ -58,28 +84,24 @@ export const FOCUS_OPTIONS: FocusOption[] = [
|
||||
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",
|
||||
},
|
||||
];
|
||||
|
||||
@ -117,9 +139,16 @@ export const INTAKE_STEPS: IntakeStep[] = [
|
||||
save: async ({ prisma, tx, userId }, answer) => {
|
||||
const option = FOCUS_OPTIONS.find((entry) => entry.id === answer.option?.id);
|
||||
if (!option) return;
|
||||
const profile = await tx.userProfile.findUnique({
|
||||
where: { userId },
|
||||
select: { locale: true, timeZone: true },
|
||||
});
|
||||
const locale = intakeContext(profile, new Date()).locale;
|
||||
await tx.userOnboarding.update({ where: { userId }, data: { focus: option.id } });
|
||||
await updateUserProfile(prisma, userId, {
|
||||
addFacts: [{ category: "goals", value: option.fact, source: "onboarding" }],
|
||||
addFacts: [
|
||||
{ category: "goals", value: focusFactValue(option.id, locale), source: "onboarding" },
|
||||
],
|
||||
});
|
||||
},
|
||||
},
|
||||
|
||||
43
apps/api/src/project-directories.test.ts
Normal file
43
apps/api/src/project-directories.test.ts
Normal file
@ -0,0 +1,43 @@
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { listHostDirectories } from "./project-directories.js";
|
||||
|
||||
describe("listHostDirectories", () => {
|
||||
it("lists directories in the user home or projects directory by default", async () => {
|
||||
const listing = await listHostDirectories();
|
||||
expect(listing.currentPath).toBeTruthy();
|
||||
expect(Array.isArray(listing.directories)).toBe(true);
|
||||
expect(Array.isArray(listing.commonPaths)).toBe(true);
|
||||
expect(listing.commonPaths.some((p) => p.name.includes("Home") || p.name === "Projects")).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
it("lists specific existing directory and excludes hidden folders and node_modules", async () => {
|
||||
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "evoo-test-dirs-"));
|
||||
try {
|
||||
await fs.mkdir(path.join(tmpDir, "visible-folder-a"));
|
||||
await fs.mkdir(path.join(tmpDir, "visible-folder-b"));
|
||||
await fs.mkdir(path.join(tmpDir, ".hidden-folder"));
|
||||
await fs.mkdir(path.join(tmpDir, "node_modules"));
|
||||
await fs.writeFile(path.join(tmpDir, "some-file.txt"), "hello");
|
||||
|
||||
const listing = await listHostDirectories(tmpDir);
|
||||
expect(listing.currentPath).toBe(await fs.realpath(tmpDir));
|
||||
expect(listing.directories.map((d) => d.name)).toEqual([
|
||||
"visible-folder-a",
|
||||
"visible-folder-b",
|
||||
]);
|
||||
} finally {
|
||||
await fs.rm(tmpDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("falls back safely when given an invalid path", async () => {
|
||||
const listing = await listHostDirectories("/nonexistent-path-12345/abcdef");
|
||||
expect(listing.currentPath).toBe(os.homedir());
|
||||
expect(Array.isArray(listing.directories)).toBe(true);
|
||||
});
|
||||
});
|
||||
95
apps/api/src/project-directories.ts
Normal file
95
apps/api/src/project-directories.ts
Normal file
@ -0,0 +1,95 @@
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
|
||||
export interface HostDirectoryListing {
|
||||
currentPath: string;
|
||||
parentPath: string | null;
|
||||
commonPaths: Array<{ name: string; path: string }>;
|
||||
directories: Array<{ name: string; path: string }>;
|
||||
}
|
||||
|
||||
export async function listHostDirectories(targetPath?: string): Promise<HostDirectoryListing> {
|
||||
const home = os.homedir();
|
||||
const raw = targetPath?.trim();
|
||||
let requestedPath = home;
|
||||
|
||||
if (raw) {
|
||||
if (raw === "~") {
|
||||
requestedPath = home;
|
||||
} else if (raw.startsWith("~/")) {
|
||||
requestedPath = path.join(home, raw.slice(2));
|
||||
} else {
|
||||
requestedPath = path.resolve(raw);
|
||||
}
|
||||
} else {
|
||||
// Default: prioritize ~/Projects if it exists, otherwise home directory
|
||||
const projectsCandidate = path.join(home, "Projects");
|
||||
try {
|
||||
const stat = await fs.stat(projectsCandidate);
|
||||
if (stat.isDirectory()) {
|
||||
requestedPath = projectsCandidate;
|
||||
}
|
||||
} catch {
|
||||
requestedPath = home;
|
||||
}
|
||||
}
|
||||
|
||||
let currentPath: string;
|
||||
try {
|
||||
currentPath = await fs.realpath(requestedPath);
|
||||
const stat = await fs.stat(currentPath);
|
||||
if (!stat.isDirectory()) {
|
||||
currentPath = home;
|
||||
}
|
||||
} catch {
|
||||
currentPath = home;
|
||||
}
|
||||
|
||||
const parsed = path.parse(currentPath);
|
||||
const parentPath = currentPath === parsed.root ? null : path.dirname(currentPath);
|
||||
|
||||
const commonCandidates = [
|
||||
{ name: "Projects", path: path.join(home, "Projects") },
|
||||
{ name: "Home (~)", path: home },
|
||||
{ name: "Desktop", path: path.join(home, "Desktop") },
|
||||
{ name: "Documents", path: path.join(home, "Documents") },
|
||||
{ name: "Downloads", path: path.join(home, "Downloads") },
|
||||
];
|
||||
|
||||
const commonPaths: Array<{ name: string; path: string }> = [];
|
||||
for (const candidate of commonCandidates) {
|
||||
try {
|
||||
const s = await fs.stat(candidate.path);
|
||||
if (s.isDirectory()) {
|
||||
commonPaths.push(candidate);
|
||||
}
|
||||
} catch {
|
||||
// not present on host
|
||||
}
|
||||
}
|
||||
|
||||
const directories: Array<{ name: string; path: string }> = [];
|
||||
try {
|
||||
const entries = await fs.readdir(currentPath, { withFileTypes: true });
|
||||
for (const entry of entries) {
|
||||
if (!entry.isDirectory()) continue;
|
||||
// Skip hidden folders (.git, .Trash, etc.) and node_modules
|
||||
if (entry.name.startsWith(".") || entry.name === "node_modules") continue;
|
||||
directories.push({
|
||||
name: entry.name,
|
||||
path: path.join(currentPath, entry.name),
|
||||
});
|
||||
}
|
||||
directories.sort((a, b) => a.name.localeCompare(b.name, undefined, { sensitivity: "base" }));
|
||||
} catch {
|
||||
// Unreadable directory returns empty list
|
||||
}
|
||||
|
||||
return {
|
||||
currentPath,
|
||||
parentPath,
|
||||
commonPaths,
|
||||
directories,
|
||||
};
|
||||
}
|
||||
114
apps/api/src/project-github.test.ts
Normal file
114
apps/api/src/project-github.test.ts
Normal file
@ -0,0 +1,114 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { listUserGithubRepos } from "./project-github.js";
|
||||
|
||||
vi.mock("node:child_process", () => ({
|
||||
execFile: vi.fn((_cmd, _args, callback) => {
|
||||
callback(new Error("gh not installed"));
|
||||
}),
|
||||
}));
|
||||
|
||||
describe("listUserGithubRepos", () => {
|
||||
it("queries repositories and filters by query", async () => {
|
||||
const mockPrisma = {
|
||||
capabilityInstall: {
|
||||
findMany: vi.fn().mockResolvedValue([
|
||||
{
|
||||
id: "cap-1",
|
||||
kind: "mcp",
|
||||
config: { preset: "github" },
|
||||
secretId: "sec-1",
|
||||
},
|
||||
]),
|
||||
},
|
||||
secret: {
|
||||
findUnique: vi.fn().mockResolvedValue({
|
||||
id: "sec-1",
|
||||
ciphertext: "fake-ciphertext",
|
||||
}),
|
||||
},
|
||||
};
|
||||
|
||||
const mockSecrets = {
|
||||
load: vi.fn().mockReturnValue("fake-token"),
|
||||
};
|
||||
|
||||
// Mock fetch for GitHub API
|
||||
const originalFetch = globalThis.fetch;
|
||||
globalThis.fetch = vi.fn().mockImplementation(async (url: string) => {
|
||||
if (url.includes("/user/repos")) {
|
||||
return {
|
||||
ok: true,
|
||||
json: async () => [
|
||||
{
|
||||
name: "evOo-local",
|
||||
full_name: "testorg/evOo-local",
|
||||
html_url: "https://github.com/testorg/evOo-local",
|
||||
description: "AI Operating System",
|
||||
private: false,
|
||||
updated_at: "2026-10-12T00:00:00Z",
|
||||
},
|
||||
{
|
||||
name: "secret-vault",
|
||||
full_name: "testorg/secret-vault",
|
||||
html_url: "https://github.com/testorg/secret-vault",
|
||||
description: "Private keys",
|
||||
private: true,
|
||||
updated_at: "2026-10-11T00:00:00Z",
|
||||
},
|
||||
],
|
||||
} as unknown as Response;
|
||||
}
|
||||
if (url.includes("/user")) {
|
||||
return {
|
||||
ok: true,
|
||||
json: async () => ({ login: "testuser" }),
|
||||
} as unknown as Response;
|
||||
}
|
||||
return { ok: false } as unknown as Response;
|
||||
});
|
||||
|
||||
try {
|
||||
const all = await listUserGithubRepos(mockPrisma as any, undefined, mockSecrets);
|
||||
expect(all.connected).toBe(true);
|
||||
expect(all.username).toBe("testuser");
|
||||
expect(all.repos).toHaveLength(2);
|
||||
expect(all.repos[0].name).toBe("evOo-local");
|
||||
|
||||
const filtered = await listUserGithubRepos(
|
||||
mockPrisma as any,
|
||||
{ query: "vault" },
|
||||
mockSecrets,
|
||||
);
|
||||
expect(filtered.repos).toHaveLength(1);
|
||||
expect(filtered.repos[0].name).toBe("secret-vault");
|
||||
expect(filtered.repos[0].isPrivate).toBe(true);
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
}
|
||||
});
|
||||
|
||||
it("handles unauthenticated state gracefully", async () => {
|
||||
const mockPrisma = {
|
||||
capabilityInstall: {
|
||||
findMany: vi.fn().mockResolvedValue([]),
|
||||
},
|
||||
secret: {
|
||||
findUnique: vi.fn().mockResolvedValue(null),
|
||||
},
|
||||
};
|
||||
|
||||
const origToken = process.env.GITHUB_TOKEN;
|
||||
const origGhToken = process.env.GH_TOKEN;
|
||||
delete process.env.GITHUB_TOKEN;
|
||||
delete process.env.GH_TOKEN;
|
||||
|
||||
try {
|
||||
const res = await listUserGithubRepos(mockPrisma as any, { query: "test" });
|
||||
expect(res.connected !== undefined).toBe(true);
|
||||
expect(Array.isArray(res.repos)).toBe(true);
|
||||
} finally {
|
||||
if (origToken) process.env.GITHUB_TOKEN = origToken;
|
||||
if (origGhToken) process.env.GH_TOKEN = origGhToken;
|
||||
}
|
||||
});
|
||||
});
|
||||
200
apps/api/src/project-github.ts
Normal file
200
apps/api/src/project-github.ts
Normal file
@ -0,0 +1,200 @@
|
||||
import { execFile } from "node:child_process";
|
||||
import { promisify } from "node:util";
|
||||
import type { EncryptedSecretStore } from "@rakazo/adapters";
|
||||
import type { PrismaClient } from "@rakazo/db";
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
|
||||
export interface GithubRepoItem {
|
||||
name: string;
|
||||
fullName: string;
|
||||
url: string;
|
||||
description?: string | null;
|
||||
isPrivate: boolean;
|
||||
updatedAt?: string;
|
||||
}
|
||||
|
||||
export interface ListGithubReposOutput {
|
||||
connected: boolean;
|
||||
username?: string;
|
||||
repos: GithubRepoItem[];
|
||||
}
|
||||
|
||||
export async function listUserGithubRepos(
|
||||
prisma: Pick<PrismaClient, "capabilityInstall" | "secret">,
|
||||
input?: { query?: string; limit?: number },
|
||||
secrets?: Pick<EncryptedSecretStore, "load">,
|
||||
): Promise<ListGithubReposOutput> {
|
||||
let token: string | null = null;
|
||||
|
||||
// 1. Check CapabilityInstall in database if secret store is provided
|
||||
if (secrets) {
|
||||
try {
|
||||
const installs = await prisma.capabilityInstall.findMany({
|
||||
where: { kind: "mcp" },
|
||||
});
|
||||
for (const inst of installs) {
|
||||
const cfg = inst.config as Record<string, unknown> | null;
|
||||
if (cfg?.preset === "github" && inst.secretId) {
|
||||
const secret = await prisma.secret.findUnique({
|
||||
where: { id: inst.secretId },
|
||||
select: { id: true, ciphertext: true },
|
||||
});
|
||||
if (secret) {
|
||||
try {
|
||||
const loaded = secrets.load(secret.ciphertext, secret.id);
|
||||
if (loaded?.trim()) {
|
||||
token = loaded.trim();
|
||||
break;
|
||||
}
|
||||
} catch {
|
||||
// decryption error fallback
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Database lookup error fallback
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Check environment
|
||||
if (!token) {
|
||||
token = process.env.GITHUB_TOKEN?.trim() || process.env.GH_TOKEN?.trim() || null;
|
||||
}
|
||||
|
||||
// 3. Check local gh CLI auth token
|
||||
if (!token) {
|
||||
try {
|
||||
const { stdout } = await execFileAsync("gh", ["auth", "token"], { timeout: 3000 });
|
||||
if (stdout.trim()) {
|
||||
token = stdout.trim();
|
||||
}
|
||||
} catch {
|
||||
// gh CLI not available or not logged in
|
||||
}
|
||||
}
|
||||
|
||||
// If token is available, query GitHub REST API
|
||||
if (token) {
|
||||
try {
|
||||
let username: string | undefined;
|
||||
const userRes = await fetch("https://api.github.com/user", {
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
"User-Agent": "evOo-App",
|
||||
Accept: "application/vnd.github+json",
|
||||
},
|
||||
});
|
||||
if (userRes.ok) {
|
||||
const userData = (await userRes.json()) as { login?: string };
|
||||
username = userData.login;
|
||||
}
|
||||
|
||||
const reposRes = await fetch(
|
||||
"https://api.github.com/user/repos?sort=pushed&per_page=100&type=all",
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
"User-Agent": "evOo-App",
|
||||
Accept: "application/vnd.github+json",
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
if (reposRes.ok) {
|
||||
const rawRepos = (await reposRes.json()) as Array<{
|
||||
name: string;
|
||||
full_name: string;
|
||||
html_url: string;
|
||||
description?: string | null;
|
||||
private?: boolean;
|
||||
pushed_at?: string;
|
||||
updated_at?: string;
|
||||
}>;
|
||||
|
||||
let items: GithubRepoItem[] = rawRepos.map((r) => ({
|
||||
name: r.name,
|
||||
fullName: r.full_name,
|
||||
url: r.html_url,
|
||||
description: r.description ?? null,
|
||||
isPrivate: Boolean(r.private),
|
||||
updatedAt: r.pushed_at || r.updated_at,
|
||||
}));
|
||||
|
||||
if (input?.query?.trim()) {
|
||||
const q = input.query.trim().toLowerCase();
|
||||
items = items.filter(
|
||||
(r) =>
|
||||
r.name.toLowerCase().includes(q) ||
|
||||
r.fullName.toLowerCase().includes(q) ||
|
||||
(r.description && r.description.toLowerCase().includes(q)),
|
||||
);
|
||||
}
|
||||
|
||||
const limit = input?.limit ?? 50;
|
||||
return {
|
||||
connected: true,
|
||||
username,
|
||||
repos: items.slice(0, limit),
|
||||
};
|
||||
}
|
||||
} catch {
|
||||
// Fall through to gh repo list fallback
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: try gh repo list directly
|
||||
try {
|
||||
const { stdout } = await execFileAsync(
|
||||
"gh",
|
||||
[
|
||||
"repo",
|
||||
"list",
|
||||
"--limit",
|
||||
"100",
|
||||
"--json",
|
||||
"nameWithOwner,name,url,description,isPrivate,pushedAt",
|
||||
],
|
||||
{ timeout: 5000 },
|
||||
);
|
||||
const parsed = JSON.parse(stdout) as Array<{
|
||||
nameWithOwner: string;
|
||||
name: string;
|
||||
url: string;
|
||||
description?: string;
|
||||
isPrivate?: boolean;
|
||||
pushedAt?: string;
|
||||
}>;
|
||||
|
||||
let items: GithubRepoItem[] = parsed.map((r) => ({
|
||||
name: r.name,
|
||||
fullName: r.nameWithOwner,
|
||||
url: r.url,
|
||||
description: r.description || null,
|
||||
isPrivate: Boolean(r.isPrivate),
|
||||
updatedAt: r.pushedAt,
|
||||
}));
|
||||
|
||||
if (input?.query?.trim()) {
|
||||
const q = input.query.trim().toLowerCase();
|
||||
items = items.filter(
|
||||
(r) =>
|
||||
r.name.toLowerCase().includes(q) ||
|
||||
r.fullName.toLowerCase().includes(q) ||
|
||||
(r.description && r.description.toLowerCase().includes(q)),
|
||||
);
|
||||
}
|
||||
|
||||
const limit = input?.limit ?? 50;
|
||||
return {
|
||||
connected: true,
|
||||
repos: items.slice(0, limit),
|
||||
};
|
||||
} catch {
|
||||
return {
|
||||
connected: false,
|
||||
repos: [],
|
||||
};
|
||||
}
|
||||
}
|
||||
@ -224,7 +224,12 @@ 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,
|
||||
modelsManaged: true,
|
||||
defaultProvider: "openrouter",
|
||||
defaultModel: "test-model",
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
@ -238,7 +243,13 @@ 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,
|
||||
modelsManaged: false,
|
||||
defaultProvider: "anthropic",
|
||||
defaultModel: "claude-opus-5-5",
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
@ -627,7 +638,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 +675,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 +700,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 +1002,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();
|
||||
});
|
||||
});
|
||||
|
||||
@ -45,10 +45,12 @@ import {
|
||||
clearInactiveUserComputerControl,
|
||||
computerSupportsUpdate,
|
||||
computerUpdateView,
|
||||
createBrowserProvider,
|
||||
createVoiceProvider,
|
||||
deploymentAutoReviewDefault,
|
||||
destroyBot,
|
||||
displayBotWorkspacePath,
|
||||
engageBrowserCowork,
|
||||
enqueueTakeoverContinuation,
|
||||
expireComputerControl,
|
||||
filterTeamWorkspaceEntries,
|
||||
@ -71,7 +73,9 @@ import {
|
||||
probeOpenAiCompatibleModels,
|
||||
provisionComputer,
|
||||
queueComputerUpdate,
|
||||
RECOMMENDED_MODEL,
|
||||
readProjectWorkspaceFile,
|
||||
recordBrowserUserAction,
|
||||
releaseComputerExecutionLease,
|
||||
replaceComputer,
|
||||
resolveAutoReviewChecker,
|
||||
@ -110,10 +114,15 @@ import {
|
||||
AttachmentValidationError,
|
||||
containsSecret,
|
||||
expandSkillReferencesInPrompt,
|
||||
GITHUB_MCP_SOURCE,
|
||||
hasMixedOneShotSchedule,
|
||||
isOneShotRoutineCrons,
|
||||
isProjectColor,
|
||||
isProjectIcon,
|
||||
isValidAvatarValue,
|
||||
nextCronDateAcrossStrict,
|
||||
} from "@rakazo/core";
|
||||
import { readProjectSummary } from "@rakazo/core/node/project-summary";
|
||||
import type { PrismaClient, ThreadEvents } from "@rakazo/db";
|
||||
import {
|
||||
BotSectionNameConflictError,
|
||||
@ -126,6 +135,7 @@ import {
|
||||
createProjectGoalRepos,
|
||||
createRepos,
|
||||
createSpaceForMember,
|
||||
DuplicateSpaceNameError,
|
||||
deleteEmptySpaceForMember,
|
||||
deleteUnreferencedCredentialSecret,
|
||||
ensureMainConversation,
|
||||
@ -141,6 +151,7 @@ import {
|
||||
isProjectGoalStatus,
|
||||
issueMessagingLinkCode,
|
||||
listSpaceUserBotIds,
|
||||
markRoutineNoticesRemoved,
|
||||
newestModelCredentialOrder,
|
||||
newestVoiceCredentialOrder,
|
||||
ownMainAgentsBlockingSpaceDeletion,
|
||||
@ -161,7 +172,9 @@ import {
|
||||
savePushSubscription,
|
||||
selectSpaceModelPreference,
|
||||
selectSpaceVoicePreference,
|
||||
syncProjectMembers,
|
||||
updateNotificationSettings,
|
||||
updateSpaceForOwner,
|
||||
wireProjectWorkspace,
|
||||
} from "@rakazo/db";
|
||||
import { getLogger } from "@rakazo/logging";
|
||||
@ -169,6 +182,13 @@ import { deleteAgentSecret, listAgentSecrets, putAgentSecret } from "./agent-sec
|
||||
import { createAgentSkillsService } from "./agent-skills.js";
|
||||
import { aiConsentStatus, allowAiConsent } from "./ai-consent.js";
|
||||
import { createOwnedArtifact, getOwnedArtifact, getSpaceArtifact } from "./artifacts.js";
|
||||
import {
|
||||
getThreadAutopilot,
|
||||
listRecentAutopilots,
|
||||
resumeThreadAutopilot,
|
||||
startThreadAutopilot,
|
||||
stopThreadAutopilot,
|
||||
} from "./autopilot.js";
|
||||
import { importableRoutines, relativeImportedFiles, safeImportPath } from "./bot-import.js";
|
||||
import { botProfileLabelsChanged, commitBotUpdate } from "./bot-update.js";
|
||||
import { createCoachService } from "./coach.js";
|
||||
@ -195,6 +215,12 @@ import {
|
||||
startMediaJob,
|
||||
updateMediaSettings,
|
||||
} from "./media-jobs.js";
|
||||
import {
|
||||
addMediaLibrarySource,
|
||||
listMediaLibrarySources,
|
||||
removeMediaLibrarySource,
|
||||
syncMediaLibrarySourceNow,
|
||||
} from "./media-library.js";
|
||||
import {
|
||||
disconnectMemoryProvider,
|
||||
getMemoryProviderConfig,
|
||||
@ -215,7 +241,9 @@ import {
|
||||
onboardingScanStatus,
|
||||
startOnboardingScan,
|
||||
} from "./onboarding-scan.js";
|
||||
import { listSpaceRuns } from "./runs.js";
|
||||
import { listHostDirectories } from "./project-directories.js";
|
||||
import { listUserGithubRepos } from "./project-github.js";
|
||||
import { listUserRuns } from "./runs.js";
|
||||
import { addScreenProxyCapability } from "./screen-proxy.js";
|
||||
import { querySpaceSearch } from "./search.js";
|
||||
import { withSerializableRetry } from "./serializable-retry.js";
|
||||
@ -232,6 +260,7 @@ import {
|
||||
assertTeachingTakeoverAllowed,
|
||||
createTaughtSkillsService,
|
||||
} from "./taught-skills.js";
|
||||
import { actOnThreadCard } from "./thread-cards.js";
|
||||
import {
|
||||
isPeerRun,
|
||||
loadAllMessages,
|
||||
@ -242,7 +271,10 @@ import {
|
||||
followUpGroupThreadMessage,
|
||||
groupDefaultBotId,
|
||||
reactToThreadMessage,
|
||||
removeQueuedMessage,
|
||||
resolveThreadTarget,
|
||||
resumeThreadRuns,
|
||||
sendQueuedThreadMessagesNow,
|
||||
sendThreadMessage,
|
||||
setThreadUnreadState,
|
||||
stopThreadRuns,
|
||||
@ -571,8 +603,11 @@ export function createRouter(deps: RouterDeps) {
|
||||
actor: Actor | null;
|
||||
signal?: AbortSignal;
|
||||
userAgent?: string;
|
||||
/** Browser origin for URLs handed back to this request; see requestWebOrigin. */
|
||||
webOrigin?: string;
|
||||
}>();
|
||||
const repos = createRepos(deps.prisma);
|
||||
const webOriginFor = (context: { webOrigin?: string }) => context.webOrigin ?? deps.env.webOrigin;
|
||||
const onboardingDeps = {
|
||||
prisma: deps.prisma,
|
||||
events: deps.events,
|
||||
@ -687,6 +722,12 @@ export function createRouter(deps: RouterDeps) {
|
||||
spaceNavigationDto(deps, context.actor, repos, groupRepos),
|
||||
),
|
||||
create: authed.spaces.create.handler(async ({ context, input }) => {
|
||||
if (input.icon !== undefined && !isProjectIcon(input.icon)) {
|
||||
throw new ORPCError("BAD_REQUEST", { message: "Unknown project icon" });
|
||||
}
|
||||
if (input.color !== undefined && !isProjectColor(input.color)) {
|
||||
throw new ORPCError("BAD_REQUEST", { message: "Unknown project color" });
|
||||
}
|
||||
// A linked folder lives on the host, and the host is the deployment owner's machine.
|
||||
if (input.workspacePath && !context.actor.isDeploymentOwner) {
|
||||
throw new ORPCError("FORBIDDEN");
|
||||
@ -709,6 +750,9 @@ export function createRouter(deps: RouterDeps) {
|
||||
currentSpaceId: context.actor.spaceId,
|
||||
userId: context.actor.userId,
|
||||
name: input.name,
|
||||
icon: input.icon,
|
||||
color: input.color,
|
||||
gitRemoteUrl: input.gitRemoteUrl,
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof SpaceLimitError || error instanceof InvalidSpaceNameError) {
|
||||
@ -716,9 +760,54 @@ export function createRouter(deps: RouterDeps) {
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
const workspace = workspacePath
|
||||
const wiredWorkspace = workspacePath
|
||||
? await wireProjectWorkspace(deps.prisma, { spaceId: space.id, workspacePath })
|
||||
: null;
|
||||
if (input.gitRemoteUrl && !wiredWorkspace?.git?.remoteUrl) {
|
||||
await deps.prisma.space.update({
|
||||
where: { id: space.id },
|
||||
data: { gitRemoteUrl: input.gitRemoteUrl },
|
||||
});
|
||||
}
|
||||
const workspace =
|
||||
wiredWorkspace ??
|
||||
(input.gitRemoteUrl
|
||||
? {
|
||||
path: null,
|
||||
git: {
|
||||
remoteUrl: input.gitRemoteUrl,
|
||||
defaultBranch: "main",
|
||||
headCommit: null,
|
||||
dirty: false,
|
||||
dirtyPaths: [],
|
||||
},
|
||||
}
|
||||
: null);
|
||||
// Without a stated goal, the linked folder's README or manifest says what this is.
|
||||
const goal =
|
||||
input.goal?.trim() || (workspacePath ? await readProjectSummary(workspacePath) : null);
|
||||
if (goal) {
|
||||
const existingGoal = await deps.prisma.memoryDocument.findFirst({
|
||||
where: { spaceId: space.id, scope: "user", path: "PROJECT_GOAL.md" },
|
||||
select: { id: true },
|
||||
});
|
||||
if (existingGoal) {
|
||||
await deps.prisma.memoryDocument.update({
|
||||
where: { id: existingGoal.id },
|
||||
data: { content: goal },
|
||||
});
|
||||
} else {
|
||||
await deps.prisma.memoryDocument.create({
|
||||
data: {
|
||||
spaceId: space.id,
|
||||
userId: context.actor.userId,
|
||||
scope: "user",
|
||||
path: "PROJECT_GOAL.md",
|
||||
content: goal,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
// A project starts as a conversation with its own main agent.
|
||||
await ensureMainConversation(deps.prisma, context.actor.userId, space.id);
|
||||
const spaceActor = await requireMembership(deps.prisma, context.actor.userId, space.id);
|
||||
@ -729,10 +818,14 @@ export function createRouter(deps: RouterDeps) {
|
||||
return {
|
||||
id: space.id,
|
||||
name: space.name,
|
||||
icon: input.icon ?? null,
|
||||
color: input.color ?? null,
|
||||
isDefault: false,
|
||||
hasContent: false,
|
||||
canDelete: true,
|
||||
canEdit: true,
|
||||
workspace,
|
||||
goal,
|
||||
bots: bots.map(spaceBotDto),
|
||||
groups: groups.map(spaceGroupDto),
|
||||
externalConversations: [],
|
||||
@ -753,6 +846,81 @@ export function createRouter(deps: RouterDeps) {
|
||||
spaceId: conversation.spaceId,
|
||||
};
|
||||
}),
|
||||
update: authed.spaces.update.handler(async ({ context, input }) => {
|
||||
if (input.icon !== undefined && input.icon !== null && !isProjectIcon(input.icon)) {
|
||||
throw new ORPCError("BAD_REQUEST", { message: "Unknown project icon" });
|
||||
}
|
||||
if (input.color !== undefined && input.color !== null && !isProjectColor(input.color)) {
|
||||
throw new ORPCError("BAD_REQUEST", { message: "Unknown project color" });
|
||||
}
|
||||
if (input.workspacePath && !context.actor.isDeploymentOwner) {
|
||||
throw new ORPCError("FORBIDDEN");
|
||||
}
|
||||
let workspacePath: string | null | undefined;
|
||||
if (input.workspacePath) {
|
||||
try {
|
||||
workspacePath = await resolveProjectWorkspaceDirectory(input.workspacePath);
|
||||
} catch (error) {
|
||||
if (error instanceof InvalidProjectWorkspacePathError) {
|
||||
throw new ORPCError("BAD_REQUEST", { message: error.message });
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
} else if (input.workspacePath === null) {
|
||||
workspacePath = null;
|
||||
}
|
||||
|
||||
try {
|
||||
await updateSpaceForOwner(deps.prisma, {
|
||||
userId: context.actor.userId,
|
||||
spaceId: input.spaceId,
|
||||
name: input.name,
|
||||
icon: input.icon,
|
||||
color: input.color,
|
||||
workspacePath,
|
||||
gitRemoteUrl: input.gitRemoteUrl,
|
||||
});
|
||||
if (workspacePath) {
|
||||
await wireProjectWorkspace(deps.prisma, { spaceId: input.spaceId, workspacePath });
|
||||
}
|
||||
} catch (error) {
|
||||
if (error instanceof SpaceNotFoundError) {
|
||||
throw new ORPCError("NOT_FOUND", { message: error.message });
|
||||
}
|
||||
if (error instanceof IsolationError) throw new ORPCError("FORBIDDEN");
|
||||
if (error instanceof InvalidSpaceNameError || error instanceof DuplicateSpaceNameError) {
|
||||
throw new ORPCError("BAD_REQUEST", { message: error.message });
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
if (input.goal !== undefined) {
|
||||
const existingGoal = await deps.prisma.memoryDocument.findFirst({
|
||||
where: { spaceId: input.spaceId, scope: "user", path: "PROJECT_GOAL.md" },
|
||||
});
|
||||
if (input.goal?.trim()) {
|
||||
if (existingGoal) {
|
||||
await deps.prisma.memoryDocument.update({
|
||||
where: { id: existingGoal.id },
|
||||
data: { content: input.goal.trim() },
|
||||
});
|
||||
} else {
|
||||
await deps.prisma.memoryDocument.create({
|
||||
data: {
|
||||
spaceId: input.spaceId,
|
||||
userId: context.actor.userId,
|
||||
scope: "user",
|
||||
path: "PROJECT_GOAL.md",
|
||||
content: input.goal.trim(),
|
||||
},
|
||||
});
|
||||
}
|
||||
} else if (existingGoal) {
|
||||
await deps.prisma.memoryDocument.delete({ where: { id: existingGoal.id } });
|
||||
}
|
||||
}
|
||||
return { ok: true as const };
|
||||
}),
|
||||
remove: authed.spaces.remove.handler(async ({ context, input }) => {
|
||||
let claimId: string | null = null;
|
||||
let claimActive = false;
|
||||
@ -912,6 +1080,15 @@ export function createRouter(deps: RouterDeps) {
|
||||
if (claimRenewal) clearInterval(claimRenewal);
|
||||
}
|
||||
}),
|
||||
listDirectories: authed.spaces.listDirectories.handler(async ({ context, input }) => {
|
||||
if (!context.actor.isDeploymentOwner) {
|
||||
throw new ORPCError("FORBIDDEN");
|
||||
}
|
||||
return listHostDirectories(input.path);
|
||||
}),
|
||||
listGithubRepos: authed.spaces.listGithubRepos.handler(async ({ input }) =>
|
||||
listUserGithubRepos(deps.prisma, input, deps.secrets),
|
||||
),
|
||||
},
|
||||
bootstrap: authed.bootstrap.handler(async ({ context, input }) => {
|
||||
const actor = context.actor;
|
||||
@ -947,7 +1124,7 @@ export function createRouter(deps: RouterDeps) {
|
||||
try {
|
||||
const { AppBootstrapSchema } = await import("@rakazo/contracts");
|
||||
AppBootstrapSchema.parse(output);
|
||||
} catch (_e: any) {}
|
||||
} catch (_e) {}
|
||||
|
||||
return output;
|
||||
}),
|
||||
@ -1199,6 +1376,7 @@ export function createRouter(deps: RouterDeps) {
|
||||
}),
|
||||
create: authed.bots.create.handler(async ({ context, input }) => {
|
||||
assertBuiltinToolNames(input.allowedBuiltinTools);
|
||||
assertAvatarValue(input.color);
|
||||
try {
|
||||
return await repos.createBot(context.actor, input);
|
||||
} catch (error) {
|
||||
@ -1353,6 +1531,7 @@ export function createRouter(deps: RouterDeps) {
|
||||
update: authed.bots.update.handler(async ({ context, input }) => {
|
||||
const existing = await repos.getBot(context.actor, input.botId);
|
||||
assertBuiltinToolNames(input.allowedBuiltinTools);
|
||||
assertAvatarValue(input.color);
|
||||
if (input.sectionId) {
|
||||
const section = await deps.prisma.botSection.findFirst({
|
||||
where: {
|
||||
@ -1485,6 +1664,10 @@ export function createRouter(deps: RouterDeps) {
|
||||
input.name,
|
||||
);
|
||||
}
|
||||
await syncProjectMembers(deps.prisma, {
|
||||
userId: context.actor.userId,
|
||||
libraryBotId: existing.id,
|
||||
});
|
||||
const bots = await repos.listBots(context.actor);
|
||||
const bot = bots.find((b) => b.id === input.botId);
|
||||
if (!bot) throw new IsolationError();
|
||||
@ -1865,11 +2048,59 @@ export function createRouter(deps: RouterDeps) {
|
||||
}
|
||||
return { ok: true as const };
|
||||
}),
|
||||
act: authed.threads.act.handler(async ({ context, input }) => {
|
||||
const target = await resolveThreadTarget(deps.prisma, context.actor, input);
|
||||
return actOnThreadCard(deps, context.actor, target, input, {
|
||||
requireModel: async () => {
|
||||
if ((await modelSetup(deps, context.actor)).needsModel) {
|
||||
throw new ORPCError("BAD_REQUEST", { message: "Connect a model to start a run." });
|
||||
}
|
||||
},
|
||||
});
|
||||
}),
|
||||
stop: authed.threads.stop.handler(async ({ context, input }) => {
|
||||
const target = await resolveThreadTarget(deps.prisma, context.actor, input);
|
||||
// Stopping the thread also stops its autopilot, or the next run would start right away.
|
||||
await stopThreadAutopilot(deps, context.actor, target);
|
||||
await stopThreadRuns(deps, context.actor, target);
|
||||
return { ok: true as const };
|
||||
}),
|
||||
resume: authed.threads.resume.handler(async ({ context, input }) => {
|
||||
if ((await modelSetup(deps, context.actor)).needsModel) {
|
||||
throw new ORPCError("BAD_REQUEST", { message: "Connect a model to start a run." });
|
||||
}
|
||||
const target = await resolveThreadTarget(deps.prisma, context.actor, input);
|
||||
if (target.kind === "bot") {
|
||||
await assertTeachingSendAllowed(deps.prisma, context.actor.spaceId, target.botId);
|
||||
}
|
||||
return resumeThreadRuns(deps, context.actor, target);
|
||||
}),
|
||||
sendNow: authed.threads.sendNow.handler(async ({ context, input }) => {
|
||||
if ((await modelSetup(deps, context.actor)).needsModel) {
|
||||
throw new ORPCError("BAD_REQUEST", { message: "Connect a model to start a run." });
|
||||
}
|
||||
const target = await resolveThreadTarget(deps.prisma, context.actor, input);
|
||||
if (target.kind === "bot") {
|
||||
await assertTeachingSendAllowed(deps.prisma, context.actor.spaceId, target.botId);
|
||||
}
|
||||
return sendQueuedThreadMessagesNow(deps, context.actor, target, {
|
||||
messageId: input.messageId,
|
||||
});
|
||||
}),
|
||||
removeQueuedMessage: authed.threads.removeQueuedMessage.handler(
|
||||
async ({ context, input }) => {
|
||||
const target = await resolveThreadTarget(deps.prisma, context.actor, input);
|
||||
if (target.kind === "bot") {
|
||||
await assertTeachingSendAllowed(deps.prisma, context.actor.spaceId, target.botId);
|
||||
}
|
||||
return removeQueuedMessage(
|
||||
{ prisma: deps.prisma, events: deps.events },
|
||||
context.actor,
|
||||
target,
|
||||
input.messageId,
|
||||
);
|
||||
},
|
||||
),
|
||||
clear: authed.threads.clear.handler(async ({ context, input }) => {
|
||||
const target = await resolveThreadTarget(deps.prisma, context.actor, input);
|
||||
const contextBotId = target.kind === "bot" ? target.botId : groupDefaultBotId(target);
|
||||
@ -2273,18 +2504,33 @@ export function createRouter(deps: RouterDeps) {
|
||||
select: { botId: true, status: true },
|
||||
})
|
||||
: null;
|
||||
const waitingForTakeover =
|
||||
const leaseRunWaits =
|
||||
executionRun?.botId === bot.id &&
|
||||
(executionRun.status === "waiting_takeover" ||
|
||||
bot.computer.controlRunId === executionLease?.runId);
|
||||
if (
|
||||
executionBlocksUserTakeover({
|
||||
hasLease: Boolean(executionLease),
|
||||
leaseExpiresAt: executionLease?.expiresAt,
|
||||
runStatus: executionRun?.status,
|
||||
takeoverRequested: waitingForTakeover,
|
||||
})
|
||||
) {
|
||||
const leaseBusy = executionBlocksUserTakeover({
|
||||
hasLease: Boolean(executionLease),
|
||||
leaseExpiresAt: executionLease?.expiresAt,
|
||||
runStatus: executionRun?.status,
|
||||
});
|
||||
// A run whose setup failed while it waited keeps waiting_takeover after its execution
|
||||
// lease is released, or behind an older run's inactive lease. Bind it anyway, or handing
|
||||
// control back never resumes it.
|
||||
const leaselessWaitingRun =
|
||||
leaseRunWaits || leaseBusy
|
||||
? null
|
||||
: await deps.prisma.run.findFirst({
|
||||
where: {
|
||||
botId: bot.id,
|
||||
spaceId: context.actor.spaceId,
|
||||
status: "waiting_takeover",
|
||||
},
|
||||
orderBy: { createdAt: "desc" },
|
||||
select: { id: true },
|
||||
});
|
||||
const waitingRunId = leaseRunWaits ? executionLease?.runId : leaselessWaitingRun?.id;
|
||||
const waitingForTakeover = leaseRunWaits || Boolean(leaselessWaitingRun);
|
||||
if (leaseBusy && !waitingForTakeover) {
|
||||
throw new ORPCError("CONFLICT", { message: "Stop the bot first" });
|
||||
}
|
||||
// Keep an inactive lease as a fencing tombstone. The next run reclaims it
|
||||
@ -2305,7 +2551,7 @@ export function createRouter(deps: RouterDeps) {
|
||||
controlLeaseId: leaseId,
|
||||
controlLeaseExpiresAt: expiresAt,
|
||||
controlBotId: bot.id,
|
||||
controlRunId: waitingForTakeover ? executionLease?.runId : null,
|
||||
controlRunId: waitingForTakeover ? waitingRunId : null,
|
||||
state: "running",
|
||||
},
|
||||
});
|
||||
@ -2615,13 +2861,18 @@ export function createRouter(deps: RouterDeps) {
|
||||
!(hasActiveComputerControl(bot.computer) && bot.computer.controlBotId === bot.id),
|
||||
);
|
||||
return {
|
||||
url: addScreenProxyCapability(viewUrl, deps.env.screenProxySecret, deps.env.webOrigin, {
|
||||
botId: bot.id,
|
||||
computerId: computer.id,
|
||||
botGeneration: bot.screenGeneration,
|
||||
computerGeneration: computer.screenGeneration,
|
||||
controlLeaseId: computer.controlLeaseId,
|
||||
}),
|
||||
url: addScreenProxyCapability(
|
||||
viewUrl,
|
||||
deps.env.screenProxySecret,
|
||||
webOriginFor(context),
|
||||
{
|
||||
botId: bot.id,
|
||||
computerId: computer.id,
|
||||
botGeneration: bot.screenGeneration,
|
||||
computerGeneration: computer.screenGeneration,
|
||||
controlLeaseId: computer.controlLeaseId,
|
||||
},
|
||||
),
|
||||
};
|
||||
}),
|
||||
heartbeat: authed.computer.heartbeat.handler(async ({ context, input }) => {
|
||||
@ -2643,6 +2894,77 @@ export function createRouter(deps: RouterDeps) {
|
||||
}
|
||||
return { ok: true as const };
|
||||
}),
|
||||
browserChromeState: authed.computer.browserChromeState.handler(async ({ context, input }) => {
|
||||
const bot = await repos.getBot(context.actor, input.botId);
|
||||
if (!bot.computer?.providerRef || bot.computer.state !== "running") {
|
||||
throw new ORPCError("BAD_REQUEST", { message: "computer must be running" });
|
||||
}
|
||||
return readBrowserChrome(deps, bot, "state", context);
|
||||
}),
|
||||
browserNavigate: authed.computer.browserNavigate.handler(async ({ context, input }) => {
|
||||
const bot = await repos.getBot(context.actor, input.botId);
|
||||
if (!bot.computer?.providerRef || bot.computer.state !== "running") {
|
||||
throw new ORPCError("BAD_REQUEST", { message: "computer must be running" });
|
||||
}
|
||||
const browser = createBrowserProvider(undefined, { sandbox: deps.sandbox });
|
||||
const result = await browser.navigate(
|
||||
toComputerRef(bot.computer),
|
||||
{ url: input.url },
|
||||
computerContext(context.actor, bot.id, "browser.navigate"),
|
||||
);
|
||||
if (result.fallback) {
|
||||
throw new ORPCError("CONFLICT", { message: result.error ?? "Browser unavailable" });
|
||||
}
|
||||
await noteBrowserNavigate(deps, context.actor, bot, input.url);
|
||||
return readBrowserChrome(deps, bot, "state", context);
|
||||
}),
|
||||
browserBack: authed.computer.browserBack.handler(async ({ context, input }) => {
|
||||
const bot = await repos.getBot(context.actor, input.botId);
|
||||
if (!bot.computer?.providerRef || bot.computer.state !== "running") {
|
||||
throw new ORPCError("BAD_REQUEST", { message: "computer must be running" });
|
||||
}
|
||||
return readBrowserChrome(deps, bot, "back", context);
|
||||
}),
|
||||
browserForward: authed.computer.browserForward.handler(async ({ context, input }) => {
|
||||
const bot = await repos.getBot(context.actor, input.botId);
|
||||
if (!bot.computer?.providerRef || bot.computer.state !== "running") {
|
||||
throw new ORPCError("BAD_REQUEST", { message: "computer must be running" });
|
||||
}
|
||||
return readBrowserChrome(deps, bot, "forward", context);
|
||||
}),
|
||||
browserReload: authed.computer.browserReload.handler(async ({ context, input }) => {
|
||||
const bot = await repos.getBot(context.actor, input.botId);
|
||||
if (!bot.computer?.providerRef || bot.computer.state !== "running") {
|
||||
throw new ORPCError("BAD_REQUEST", { message: "computer must be running" });
|
||||
}
|
||||
return readBrowserChrome(deps, bot, "reload", context);
|
||||
}),
|
||||
browserCoworkEngage: authed.computer.browserCoworkEngage.handler(
|
||||
async ({ context, input }) => {
|
||||
const bot = await repos.getBot(context.actor, input.botId);
|
||||
if (!bot.computer) throw new IsolationError();
|
||||
const engaged = await engageBrowserCowork(
|
||||
deps,
|
||||
context.actor,
|
||||
bot,
|
||||
computerContext(context.actor, bot.id, "browser.cowork"),
|
||||
);
|
||||
return engaged;
|
||||
},
|
||||
),
|
||||
browserUserAction: authed.computer.browserUserAction.handler(async ({ context, input }) => {
|
||||
const bot = await repos.getBot(context.actor, input.botId);
|
||||
const threadId = bot.thread?.id;
|
||||
if (!threadId) throw new IsolationError();
|
||||
await recordBrowserUserAction(deps, context.actor, {
|
||||
botId: bot.id,
|
||||
threadId,
|
||||
kind: input.kind,
|
||||
detail: input.detail,
|
||||
passwordField: input.passwordField,
|
||||
});
|
||||
return { ok: true as const };
|
||||
}),
|
||||
},
|
||||
memory: {
|
||||
list: authed.memory.list.handler(async ({ context, input }) => {
|
||||
@ -2906,6 +3228,8 @@ export function createRouter(deps: RouterDeps) {
|
||||
if (!existing) throw new IsolationError();
|
||||
await deps.prisma.routine.delete({ where: { id: existing.id } });
|
||||
await deps.jobs.cancel(routineJobKey(existing.id));
|
||||
const marked = await markRoutineNoticesRemoved(deps.prisma, existing).catch(() => null);
|
||||
if (marked) await deps.events.notify(marked.threadId, marked.seq).catch(() => undefined);
|
||||
return { ok: true as const };
|
||||
}),
|
||||
testRun: authed.routines.testRun.handler(async ({ context, input }) => {
|
||||
@ -3100,8 +3424,9 @@ export function createRouter(deps: RouterDeps) {
|
||||
},
|
||||
capabilities: {
|
||||
list: authed.capabilities.list.handler(async ({ context }) => {
|
||||
// Tool sources and plugins follow the user into every space.
|
||||
const rows = await deps.prisma.capabilityInstall.findMany({
|
||||
where: { spaceId: context.actor.spaceId, userId: context.actor.userId },
|
||||
where: { userId: context.actor.userId },
|
||||
});
|
||||
return rows.map((row) => ({
|
||||
id: row.id,
|
||||
@ -3166,6 +3491,10 @@ export function createRouter(deps: RouterDeps) {
|
||||
source = "https://treg.to/mcp/";
|
||||
config = { ...config, preset: "treg", auth: { type: "bearer" } };
|
||||
}
|
||||
if (config.preset === "github") {
|
||||
source = GITHUB_MCP_SOURCE;
|
||||
config = { preset: "github", auth: { type: "bearer" } };
|
||||
}
|
||||
const verified = await verifyMcpInstall({
|
||||
source,
|
||||
config,
|
||||
@ -3256,11 +3585,7 @@ export function createRouter(deps: RouterDeps) {
|
||||
remove: authed.capabilities.remove.handler(async ({ context, input }) => {
|
||||
await deps.prisma.$transaction(async (tx) => {
|
||||
const existing = await tx.capabilityInstall.findFirst({
|
||||
where: {
|
||||
id: input.id,
|
||||
spaceId: context.actor.spaceId,
|
||||
userId: context.actor.userId,
|
||||
},
|
||||
where: { id: input.id, userId: context.actor.userId },
|
||||
});
|
||||
if (!existing) return;
|
||||
await tx.capabilityInstall.delete({ where: { id: existing.id } });
|
||||
@ -3270,11 +3595,7 @@ export function createRouter(deps: RouterDeps) {
|
||||
});
|
||||
if (shared === 0) {
|
||||
await tx.secret.deleteMany({
|
||||
where: {
|
||||
id: existing.secretId,
|
||||
spaceId: context.actor.spaceId,
|
||||
userId: context.actor.userId,
|
||||
},
|
||||
where: { id: existing.secretId, userId: context.actor.userId },
|
||||
});
|
||||
}
|
||||
}
|
||||
@ -3460,11 +3781,7 @@ export function createRouter(deps: RouterDeps) {
|
||||
});
|
||||
} else if (clearing && existing.secretId) {
|
||||
await tx.secret.deleteMany({
|
||||
where: {
|
||||
id: existing.secretId,
|
||||
spaceId: context.actor.spaceId,
|
||||
userId: context.actor.userId,
|
||||
},
|
||||
where: { id: existing.secretId, userId: context.actor.userId },
|
||||
});
|
||||
}
|
||||
return updated;
|
||||
@ -3621,7 +3938,10 @@ export function createRouter(deps: RouterDeps) {
|
||||
oauth: {
|
||||
begin: authed.mcp.oauth.begin.handler(async ({ context, input }) => {
|
||||
try {
|
||||
const expectedRedirect = new URL("/mcp/oauth/callback", deps.env.webOrigin).toString();
|
||||
const expectedRedirect = new URL(
|
||||
"/mcp/oauth/callback",
|
||||
webOriginFor(context),
|
||||
).toString();
|
||||
if (new URL(input.redirectUri).toString() !== expectedRedirect) {
|
||||
throw new Error("MCP OAuth redirect URI is not allowed");
|
||||
}
|
||||
@ -3716,7 +4036,7 @@ export function createRouter(deps: RouterDeps) {
|
||||
return {
|
||||
canConfigure,
|
||||
needsSetup: canConfigure && !providers.some((provider) => provider.configured),
|
||||
webUrl: new URL("/integrations/setup", deps.env.webOrigin).toString(),
|
||||
webUrl: new URL("/integrations/setup", webOriginFor(context)).toString(),
|
||||
providers,
|
||||
};
|
||||
}),
|
||||
@ -3759,6 +4079,20 @@ export function createRouter(deps: RouterDeps) {
|
||||
cancelMediaJob(deps, context.actor, input.jobId),
|
||||
),
|
||||
},
|
||||
library: {
|
||||
list: authed.media.library.list.handler(({ context }) =>
|
||||
listMediaLibrarySources(deps, context.actor),
|
||||
),
|
||||
add: authed.media.library.add.handler(({ context, input }) =>
|
||||
addMediaLibrarySource(deps, context.actor, input),
|
||||
),
|
||||
remove: authed.media.library.remove.handler(({ context, input }) =>
|
||||
removeMediaLibrarySource(deps, context.actor, input.sourceId),
|
||||
),
|
||||
sync: authed.media.library.sync.handler(({ context, input }) =>
|
||||
syncMediaLibrarySourceNow(deps, context.actor, input.sourceId),
|
||||
),
|
||||
},
|
||||
},
|
||||
connections: {
|
||||
catalog: authed.connections.catalog.handler(async ({ context, input }) => {
|
||||
@ -3840,7 +4174,7 @@ export function createRouter(deps: RouterDeps) {
|
||||
});
|
||||
try {
|
||||
const auth = await connector.begin(
|
||||
{ provider: input.provider, redirectUrl: `${deps.env.webOrigin}/app` },
|
||||
{ provider: input.provider, redirectUrl: `${webOriginFor(context)}/app` },
|
||||
connectionContext(context.actor, "connections.begin", context.signal),
|
||||
);
|
||||
// Re-take the provider lock and only advance still-pending rows so a
|
||||
@ -5101,7 +5435,7 @@ export function createRouter(deps: RouterDeps) {
|
||||
},
|
||||
runs: {
|
||||
list: authed.runs.list.handler(async ({ context, input }) => ({
|
||||
runs: await listSpaceRuns(deps.prisma, context.actor, input.filter),
|
||||
runs: await listUserRuns(deps.prisma, context.actor, input.filter),
|
||||
})),
|
||||
},
|
||||
tasks: {
|
||||
@ -5114,6 +5448,33 @@ export function createRouter(deps: RouterDeps) {
|
||||
items: await listInbox(deps.prisma, context.actor),
|
||||
})),
|
||||
},
|
||||
autopilot: {
|
||||
get: authed.autopilot.get.handler(async ({ context, input }) => {
|
||||
const target = await resolveThreadTarget(deps.prisma, context.actor, input);
|
||||
return getThreadAutopilot(deps, context.actor, target);
|
||||
}),
|
||||
start: authed.autopilot.start.handler(async ({ context, input }) => {
|
||||
if ((await modelSetup(deps, context.actor)).needsModel) {
|
||||
throw new ORPCError("BAD_REQUEST", { message: "Connect a model to start a run." });
|
||||
}
|
||||
const target = await resolveThreadTarget(deps.prisma, context.actor, input);
|
||||
return startThreadAutopilot(deps, context.actor, target, input);
|
||||
}),
|
||||
stop: authed.autopilot.stop.handler(async ({ context, input }) => {
|
||||
const target = await resolveThreadTarget(deps.prisma, context.actor, input);
|
||||
return stopThreadAutopilot(deps, context.actor, target);
|
||||
}),
|
||||
resume: authed.autopilot.resume.handler(async ({ context, input }) => {
|
||||
if ((await modelSetup(deps, context.actor)).needsModel) {
|
||||
throw new ORPCError("BAD_REQUEST", { message: "Connect a model to start a run." });
|
||||
}
|
||||
const target = await resolveThreadTarget(deps.prisma, context.actor, input);
|
||||
return resumeThreadAutopilot(deps, context.actor, target);
|
||||
}),
|
||||
recent: authed.autopilot.recent.handler(async ({ context }) => ({
|
||||
items: await listRecentAutopilots(deps, context.actor),
|
||||
})),
|
||||
},
|
||||
voice: {
|
||||
catalog: authed.voice.catalog.handler(async () => listVoiceCatalog()),
|
||||
status: authed.voice.status.handler(async ({ context }) => {
|
||||
@ -5270,6 +5631,8 @@ async function spaceNavigationDto(
|
||||
space: {
|
||||
select: {
|
||||
name: true,
|
||||
icon: true,
|
||||
color: true,
|
||||
isDefault: true,
|
||||
deletingAt: true,
|
||||
workspacePath: true,
|
||||
@ -5297,6 +5660,7 @@ async function spaceNavigationDto(
|
||||
externalConversations,
|
||||
contentBots,
|
||||
contentGroups,
|
||||
goalDocuments,
|
||||
] = await Promise.all([
|
||||
repos.listBots(actor),
|
||||
groupRepos.listGroups(actor),
|
||||
@ -5310,19 +5674,29 @@ async function spaceNavigationDto(
|
||||
// The user's own main agent of a project is part of the project, not content to clear.
|
||||
deps.prisma.bot.findMany({
|
||||
where: { spaceId: { in: spaceIds }, NOT: ownProjectMain },
|
||||
select: { spaceId: true },
|
||||
distinct: ["spaceId"],
|
||||
select: { spaceId: true, userId: true },
|
||||
distinct: ["spaceId", "userId"],
|
||||
}),
|
||||
deps.prisma.chatGroup.findMany({
|
||||
where: { spaceId: { in: spaceIds }, NOT: ownProjectMain },
|
||||
select: { spaceId: true },
|
||||
distinct: ["spaceId"],
|
||||
select: { spaceId: true, userId: true },
|
||||
distinct: ["spaceId", "userId"],
|
||||
}),
|
||||
deps.prisma.memoryDocument.findMany({
|
||||
where: { spaceId: { in: spaceIds }, scope: "user", path: "PROJECT_GOAL.md" },
|
||||
select: { spaceId: true, content: true },
|
||||
}),
|
||||
]);
|
||||
const spacesWithContent = new Set([
|
||||
...contentBots.map((row) => row.spaceId),
|
||||
...contentGroups.map((row) => row.spaceId),
|
||||
]);
|
||||
const spacesBlockedByOtherMember = new Set(
|
||||
[...contentBots, ...contentGroups]
|
||||
.filter((row) => row.userId !== actor.userId)
|
||||
.map((row) => row.spaceId),
|
||||
);
|
||||
const goalBySpaceId = new Map(goalDocuments.map((doc) => [doc.spaceId, doc.content]));
|
||||
const currentMembership = memberships.find((membership) => membership.spaceId === actor.spaceId);
|
||||
if (!currentMembership) throw new IsolationError();
|
||||
const botsBySpace = partitionBySpace([...currentBots, ...inactiveBots]);
|
||||
@ -5350,16 +5724,20 @@ async function spaceNavigationDto(
|
||||
return {
|
||||
id: membership.spaceId,
|
||||
name: membership.space.name,
|
||||
icon: membership.space.icon,
|
||||
color: membership.space.color,
|
||||
isDefault: membership.space.isDefault,
|
||||
hasContent: spacesWithContent.has(membership.spaceId),
|
||||
canDelete:
|
||||
membership.role === "owner" &&
|
||||
!membership.space.isDefault &&
|
||||
memberships.length > 1 &&
|
||||
!spacesWithContent.has(membership.spaceId) &&
|
||||
!spacesBlockedByOtherMember.has(membership.spaceId) &&
|
||||
(membership.space.deletingAt === null || membership.space.deletingAt < staleClaimBefore),
|
||||
canEdit: membership.role === "owner",
|
||||
// A linked folder is on the owner's host; other members see neither its path nor remote.
|
||||
workspace: actor.isDeploymentOwner ? projectWorkspaceDto(membership.space) : null,
|
||||
goal: goalBySpaceId.get(membership.spaceId) ?? null,
|
||||
bots: spaceBots.map(spaceBotDto),
|
||||
groups: spaceGroups.map(spaceGroupDto),
|
||||
externalConversations: externalConversations.filter(
|
||||
@ -5447,6 +5825,10 @@ async function meDto(deps: RouterDeps, actor: Actor): Promise<Me> {
|
||||
deps.prisma.user.findUniqueOrThrow({ where: { id: actor.userId } }),
|
||||
modelSetup(deps, actor),
|
||||
]);
|
||||
// Without a key the deployment default cannot run, so the connect flow opens on the recommendation.
|
||||
const fallback = setup.needsModel
|
||||
? RECOMMENDED_MODEL
|
||||
: { provider: deps.env.defaultProvider, model: deps.env.defaultModel };
|
||||
return {
|
||||
userId: actor.userId,
|
||||
email: user.email,
|
||||
@ -5454,12 +5836,11 @@ async function meDto(deps: RouterDeps, actor: Actor): Promise<Me> {
|
||||
spaceId: actor.spaceId,
|
||||
isDeploymentOwner: actor.isDeploymentOwner,
|
||||
needsModel: setup.needsModel,
|
||||
modelsManaged: Boolean(deps.env.deploymentModelKey),
|
||||
defaultProvider:
|
||||
setup.credential?.provider ??
|
||||
setup.settings?.defaultModelProvider ??
|
||||
deps.env.defaultProvider,
|
||||
setup.credential?.provider ?? setup.settings?.defaultModelProvider ?? fallback.provider,
|
||||
defaultModel:
|
||||
setup.credential?.defaultModel ?? setup.settings?.defaultModelId ?? deps.env.defaultModel,
|
||||
setup.credential?.defaultModel ?? setup.settings?.defaultModelId ?? fallback.model,
|
||||
computerHost: computerHostFor(setup.settings?.computerHost, deps.env.sandboxProvider),
|
||||
canChooseHostComputer: actor.isDeploymentOwner && deps.env.sandboxProvider === "docker",
|
||||
sandboxProvider: deps.env.sandboxProvider,
|
||||
@ -5754,6 +6135,55 @@ async function persistModelCredential(
|
||||
return modelCredentialDto(cred, input.plaintext);
|
||||
}
|
||||
|
||||
async function readBrowserChrome(
|
||||
deps: RouterDeps,
|
||||
bot: {
|
||||
id: string;
|
||||
computer: { homeKey: string; kind: string; providerRef: string | null } | null;
|
||||
},
|
||||
command: "state" | "back" | "forward" | "reload",
|
||||
context: { actor: Actor },
|
||||
) {
|
||||
if (!bot.computer?.providerRef) {
|
||||
throw new ORPCError("BAD_REQUEST", { message: "computer must be running" });
|
||||
}
|
||||
const browser = createBrowserProvider(undefined, { sandbox: deps.sandbox });
|
||||
const chrome = browser as InstanceType<typeof import("@rakazo/adapters").ComputerBrowserProvider>;
|
||||
const result = await chrome.chrome(
|
||||
toComputerRef(bot.computer),
|
||||
command,
|
||||
computerContext(context.actor, bot.id, `browser.${command}`),
|
||||
);
|
||||
if (!result.ok) {
|
||||
throw new ORPCError("CONFLICT", {
|
||||
message: "error" in result && result.error ? result.error : "Browser unavailable",
|
||||
});
|
||||
}
|
||||
return {
|
||||
url: result.url,
|
||||
title: result.title,
|
||||
loading: result.loading,
|
||||
canGoBack: result.canGoBack,
|
||||
canGoForward: result.canGoForward,
|
||||
};
|
||||
}
|
||||
|
||||
async function noteBrowserNavigate(
|
||||
deps: RouterDeps,
|
||||
actor: Actor,
|
||||
bot: { id: string; thread: { id: string } | null },
|
||||
url: string,
|
||||
) {
|
||||
const threadId = bot.thread?.id;
|
||||
if (!threadId) return;
|
||||
await recordBrowserUserAction(deps, actor, {
|
||||
botId: bot.id,
|
||||
threadId,
|
||||
kind: "navigate",
|
||||
detail: url,
|
||||
});
|
||||
}
|
||||
|
||||
function throwIfAborted(signal?: AbortSignal) {
|
||||
if (signal?.aborted) throw signal.reason ?? new Error("Request cancelled");
|
||||
}
|
||||
@ -5830,6 +6260,12 @@ function assertBuiltinToolNames(names: readonly string[] | undefined) {
|
||||
}
|
||||
}
|
||||
|
||||
function assertAvatarValue(value: string | undefined) {
|
||||
if (value !== undefined && !isValidAvatarValue(value)) {
|
||||
throw new ORPCError("BAD_REQUEST", { message: "Invalid avatar" });
|
||||
}
|
||||
}
|
||||
|
||||
function duplicateBotName(name: string) {
|
||||
return `${name.slice(0, 75)} copy`;
|
||||
}
|
||||
|
||||
@ -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;
|
||||
@ -45,15 +49,19 @@ export function activityNotificationsEnabled(
|
||||
return groupId !== null || notifyOnFinish;
|
||||
}
|
||||
|
||||
export async function listSpaceRuns(
|
||||
/**
|
||||
* The user's runs across every space they belong to, so a chat waiting on them in another
|
||||
* project still shows up wherever they look.
|
||||
*/
|
||||
export async function listUserRuns(
|
||||
prisma: PrismaClient,
|
||||
actor: Actor,
|
||||
filter: "active" | "recent",
|
||||
): Promise<RunActivityRow[]> {
|
||||
const rows = await prisma.run.findMany({
|
||||
where: {
|
||||
spaceId: actor.spaceId,
|
||||
userId: actor.userId,
|
||||
space: { memberships: { some: { userId: actor.userId } } },
|
||||
bot: { archivedAt: null },
|
||||
...(filter === "active"
|
||||
? { status: { in: [...ACTIVE_RUN_STATUSES] } }
|
||||
@ -84,6 +92,7 @@ export async function listSpaceRuns(
|
||||
groupId: row.thread.groupId,
|
||||
groupName: row.thread.group?.name ?? null,
|
||||
threadId: row.threadId,
|
||||
spaceId: row.spaceId,
|
||||
status: row.status as RunActivityRow["status"],
|
||||
trigger: row.trigger as RunActivityRow["trigger"],
|
||||
notificationsEnabled: activityNotificationsEnabled(row.thread.groupId, row.bot.notifyOnFinish),
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
import type { Actor, MessageBlock, SearchHit } from "@rakazo/contracts";
|
||||
import { extractLinksFromText, matchesSearchQuery, snippetAroundMatch } from "@rakazo/core";
|
||||
import { Prisma, type PrismaClient } from "@rakazo/db";
|
||||
import type { PrismaClient } from "@rakazo/db";
|
||||
import { Prisma } from "@rakazo/db";
|
||||
|
||||
const SEARCH_LIMIT = 25;
|
||||
/** Cap name matches so content hits (messages/files/links/routines) keep most of the budget. */
|
||||
@ -64,19 +65,65 @@ export async function querySpaceSearch(
|
||||
bucket.push(hit);
|
||||
}
|
||||
|
||||
const bots = await prisma.bot.findMany({
|
||||
where: {
|
||||
spaceId: actor.spaceId,
|
||||
userId: actor.userId,
|
||||
archivedAt: null,
|
||||
OR: [
|
||||
{ name: { contains: query, mode: "insensitive" } },
|
||||
{ title: { contains: query, mode: "insensitive" } },
|
||||
{ description: { contains: query, mode: "insensitive" } },
|
||||
],
|
||||
},
|
||||
take: CONVERSATION_HIT_LIMIT,
|
||||
});
|
||||
const [bots, groups, artifacts, groupArtifacts, routines] = await Promise.all([
|
||||
prisma.bot.findMany({
|
||||
where: {
|
||||
spaceId: actor.spaceId,
|
||||
userId: actor.userId,
|
||||
archivedAt: null,
|
||||
OR: [
|
||||
{ name: { contains: query, mode: "insensitive" } },
|
||||
{ title: { contains: query, mode: "insensitive" } },
|
||||
{ description: { contains: query, mode: "insensitive" } },
|
||||
],
|
||||
},
|
||||
take: CONVERSATION_HIT_LIMIT,
|
||||
}),
|
||||
prisma.chatGroup.findMany({
|
||||
where: {
|
||||
spaceId: actor.spaceId,
|
||||
userId: actor.userId,
|
||||
name: { contains: query, mode: "insensitive" },
|
||||
},
|
||||
take: CONVERSATION_HIT_LIMIT,
|
||||
}),
|
||||
prisma.artifact.findMany({
|
||||
where: {
|
||||
spaceId: actor.spaceId,
|
||||
userId: actor.userId,
|
||||
groupId: null,
|
||||
botId: { not: null },
|
||||
name: { contains: query, mode: "insensitive" },
|
||||
bot: { archivedAt: null },
|
||||
},
|
||||
include: { bot: { select: { name: true } } },
|
||||
take: SEARCH_LIMIT,
|
||||
}),
|
||||
prisma.artifact.findMany({
|
||||
where: {
|
||||
spaceId: actor.spaceId,
|
||||
userId: actor.userId,
|
||||
groupId: { not: null },
|
||||
name: { contains: query, mode: "insensitive" },
|
||||
},
|
||||
include: { group: { select: { name: true } } },
|
||||
take: SEARCH_LIMIT,
|
||||
}),
|
||||
prisma.routine.findMany({
|
||||
where: {
|
||||
spaceId: actor.spaceId,
|
||||
userId: actor.userId,
|
||||
OR: [
|
||||
{ name: { contains: query, mode: "insensitive" } },
|
||||
{ prompt: { contains: query, mode: "insensitive" } },
|
||||
],
|
||||
bot: { archivedAt: null },
|
||||
},
|
||||
include: { bot: { select: { name: true } } },
|
||||
take: SEARCH_LIMIT,
|
||||
}),
|
||||
]);
|
||||
|
||||
for (const bot of bots) {
|
||||
pushInto(
|
||||
conversationHits,
|
||||
@ -91,14 +138,6 @@ export async function querySpaceSearch(
|
||||
);
|
||||
}
|
||||
|
||||
const groups = await prisma.chatGroup.findMany({
|
||||
where: {
|
||||
spaceId: actor.spaceId,
|
||||
userId: actor.userId,
|
||||
name: { contains: query, mode: "insensitive" },
|
||||
},
|
||||
take: CONVERSATION_HIT_LIMIT,
|
||||
});
|
||||
for (const group of groups) {
|
||||
pushInto(
|
||||
conversationHits,
|
||||
@ -114,27 +153,72 @@ export async function querySpaceSearch(
|
||||
}
|
||||
|
||||
const contentBudget = Math.max(0, SEARCH_LIMIT - conversationHits.length);
|
||||
const pattern = `%${query}%`;
|
||||
|
||||
const [botArtifactMessages, groupArtifactMessages, messageRows, groupMessageRows] =
|
||||
await Promise.all([
|
||||
findArtifactMessages(
|
||||
prisma,
|
||||
actor,
|
||||
artifacts.flatMap((artifact) =>
|
||||
artifact.botId && artifact.bot ? [{ id: artifact.id, targetId: artifact.botId }] : [],
|
||||
),
|
||||
"bot",
|
||||
),
|
||||
findArtifactMessages(
|
||||
prisma,
|
||||
actor,
|
||||
groupArtifacts.flatMap((artifact) =>
|
||||
artifact.groupId && artifact.group
|
||||
? [{ id: artifact.id, targetId: artifact.groupId }]
|
||||
: [],
|
||||
),
|
||||
"group",
|
||||
),
|
||||
prisma.$queryRaw<
|
||||
Array<{
|
||||
id: string;
|
||||
threadId: string;
|
||||
seq: number;
|
||||
blocks: Prisma.JsonValue;
|
||||
botId: string;
|
||||
botName: string;
|
||||
}>
|
||||
>`
|
||||
SELECT m.id, m."threadId", m.seq, m.blocks, b.id AS "botId", b.name AS "botName"
|
||||
FROM messages m
|
||||
INNER JOIN threads t ON t.id = m."threadId"
|
||||
INNER JOIN bots b ON b.id = t."botId"
|
||||
WHERE t."spaceId" = ${actor.spaceId}
|
||||
AND t."userId" = ${actor.userId}
|
||||
AND b."archivedAt" IS NULL
|
||||
AND m.blocks::text ILIKE ${pattern}
|
||||
ORDER BY m."createdAt" DESC
|
||||
LIMIT ${SEARCH_LIMIT}
|
||||
`,
|
||||
prisma.$queryRaw<
|
||||
Array<{
|
||||
id: string;
|
||||
threadId: string;
|
||||
seq: number;
|
||||
blocks: Prisma.JsonValue;
|
||||
groupId: string;
|
||||
groupName: string;
|
||||
}>
|
||||
>`
|
||||
SELECT m.id, m."threadId", m.seq, m.blocks, g.id AS "groupId", g.name AS "groupName"
|
||||
FROM messages m
|
||||
INNER JOIN threads t ON t.id = m."threadId"
|
||||
INNER JOIN chat_groups g ON g.id = t."groupId"
|
||||
WHERE t."spaceId" = ${actor.spaceId}
|
||||
AND t."userId" = ${actor.userId}
|
||||
AND t."groupId" IS NOT NULL
|
||||
AND m.blocks::text ILIKE ${pattern}
|
||||
ORDER BY m."createdAt" DESC
|
||||
LIMIT ${SEARCH_LIMIT}
|
||||
`,
|
||||
]);
|
||||
|
||||
const artifacts = await prisma.artifact.findMany({
|
||||
where: {
|
||||
spaceId: actor.spaceId,
|
||||
userId: actor.userId,
|
||||
groupId: null,
|
||||
botId: { not: null },
|
||||
name: { contains: query, mode: "insensitive" },
|
||||
bot: { archivedAt: null },
|
||||
},
|
||||
include: { bot: { select: { name: true } } },
|
||||
take: SEARCH_LIMIT,
|
||||
});
|
||||
const botArtifactMessages = await findArtifactMessages(
|
||||
prisma,
|
||||
actor,
|
||||
artifacts.flatMap((artifact) =>
|
||||
artifact.botId && artifact.bot ? [{ id: artifact.id, targetId: artifact.botId }] : [],
|
||||
),
|
||||
"bot",
|
||||
);
|
||||
for (const artifact of artifacts) {
|
||||
if (!artifact.botId || !artifact.bot) continue;
|
||||
const message = botArtifactMessages.get(artifact.id);
|
||||
@ -155,24 +239,6 @@ export async function querySpaceSearch(
|
||||
);
|
||||
}
|
||||
|
||||
const groupArtifacts = await prisma.artifact.findMany({
|
||||
where: {
|
||||
spaceId: actor.spaceId,
|
||||
userId: actor.userId,
|
||||
groupId: { not: null },
|
||||
name: { contains: query, mode: "insensitive" },
|
||||
},
|
||||
include: { group: { select: { name: true } } },
|
||||
take: SEARCH_LIMIT,
|
||||
});
|
||||
const groupArtifactMessages = await findArtifactMessages(
|
||||
prisma,
|
||||
actor,
|
||||
groupArtifacts.flatMap((artifact) =>
|
||||
artifact.groupId && artifact.group ? [{ id: artifact.id, targetId: artifact.groupId }] : [],
|
||||
),
|
||||
"group",
|
||||
);
|
||||
for (const artifact of groupArtifacts) {
|
||||
if (!artifact.groupId || !artifact.group) continue;
|
||||
const message = groupArtifactMessages.get(artifact.id);
|
||||
@ -193,19 +259,6 @@ export async function querySpaceSearch(
|
||||
);
|
||||
}
|
||||
|
||||
const routines = await prisma.routine.findMany({
|
||||
where: {
|
||||
spaceId: actor.spaceId,
|
||||
userId: actor.userId,
|
||||
OR: [
|
||||
{ name: { contains: query, mode: "insensitive" } },
|
||||
{ prompt: { contains: query, mode: "insensitive" } },
|
||||
],
|
||||
bot: { archivedAt: null },
|
||||
},
|
||||
include: { bot: { select: { name: true } } },
|
||||
take: SEARCH_LIMIT,
|
||||
});
|
||||
for (const routine of routines) {
|
||||
pushInto(
|
||||
contentHits,
|
||||
@ -221,29 +274,6 @@ export async function querySpaceSearch(
|
||||
);
|
||||
}
|
||||
|
||||
const pattern = `%${query}%`;
|
||||
const messageRows = await prisma.$queryRaw<
|
||||
Array<{
|
||||
id: string;
|
||||
threadId: string;
|
||||
seq: number;
|
||||
blocks: Prisma.JsonValue;
|
||||
botId: string;
|
||||
botName: string;
|
||||
}>
|
||||
>`
|
||||
SELECT m.id, m."threadId", m.seq, m.blocks, b.id AS "botId", b.name AS "botName"
|
||||
FROM messages m
|
||||
INNER JOIN threads t ON t.id = m."threadId"
|
||||
INNER JOIN bots b ON b.id = t."botId"
|
||||
WHERE t."spaceId" = ${actor.spaceId}
|
||||
AND t."userId" = ${actor.userId}
|
||||
AND b."archivedAt" IS NULL
|
||||
AND m.blocks::text ILIKE ${pattern}
|
||||
ORDER BY m."createdAt" DESC
|
||||
LIMIT ${SEARCH_LIMIT}
|
||||
`;
|
||||
|
||||
for (const row of messageRows) {
|
||||
pushMessageHits(row.blocks as MessageBlock[], {
|
||||
botId: row.botId,
|
||||
@ -255,28 +285,6 @@ export async function querySpaceSearch(
|
||||
});
|
||||
}
|
||||
|
||||
const groupMessageRows = await prisma.$queryRaw<
|
||||
Array<{
|
||||
id: string;
|
||||
threadId: string;
|
||||
seq: number;
|
||||
blocks: Prisma.JsonValue;
|
||||
groupId: string;
|
||||
groupName: string;
|
||||
}>
|
||||
>`
|
||||
SELECT m.id, m."threadId", m.seq, m.blocks, g.id AS "groupId", g.name AS "groupName"
|
||||
FROM messages m
|
||||
INNER JOIN threads t ON t.id = m."threadId"
|
||||
INNER JOIN chat_groups g ON g.id = t."groupId"
|
||||
WHERE t."spaceId" = ${actor.spaceId}
|
||||
AND t."userId" = ${actor.userId}
|
||||
AND t."groupId" IS NOT NULL
|
||||
AND m.blocks::text ILIKE ${pattern}
|
||||
ORDER BY m."createdAt" DESC
|
||||
LIMIT ${SEARCH_LIMIT}
|
||||
`;
|
||||
|
||||
for (const row of groupMessageRows) {
|
||||
pushMessageHits(row.blocks as MessageBlock[], {
|
||||
groupId: row.groupId,
|
||||
|
||||
@ -90,7 +90,7 @@ describe("server update install kind", () => {
|
||||
if (href.endsWith("/state")) {
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
image: "ghcr.io/elie222/rakazo/app",
|
||||
image: "ghcr.io/cynarai/evoo-local/app",
|
||||
currentTag: "sha-aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
|
||||
previousTag: "sha-bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
|
||||
running: false,
|
||||
@ -98,7 +98,7 @@ describe("server update install kind", () => {
|
||||
present: true,
|
||||
commit: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
|
||||
branch: "main",
|
||||
remoteUrl: "https://github.com/elie222/rakazo",
|
||||
remoteUrl: "https://github.com/cynarAI/evOo-local",
|
||||
dirty: false,
|
||||
dirtyPaths: [],
|
||||
},
|
||||
@ -134,7 +134,7 @@ describe("server update install kind", () => {
|
||||
fromTag: "sha-aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
|
||||
toTag: "sha-bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
|
||||
strategy: "pull",
|
||||
repoUrl: "https://github.com/elie222/rakazo",
|
||||
repoUrl: "https://github.com/cynarAI/evOo-local",
|
||||
branch: "main",
|
||||
restart: "not-required",
|
||||
restartAdvice: "Recreate failed; prior image restored, env pin not restored.",
|
||||
@ -149,7 +149,7 @@ describe("server update install kind", () => {
|
||||
if (href.endsWith("/state")) {
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
image: "ghcr.io/elie222/rakazo/app",
|
||||
image: "ghcr.io/cynarai/evoo-local/app",
|
||||
currentTag: "sha-bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
|
||||
previousTag: "sha-aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
|
||||
running: false,
|
||||
@ -158,7 +158,7 @@ describe("server update install kind", () => {
|
||||
present: true,
|
||||
commit: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
|
||||
branch: "main",
|
||||
remoteUrl: "https://github.com/elie222/rakazo",
|
||||
remoteUrl: "https://github.com/cynarAI/evOo-local",
|
||||
dirty: false,
|
||||
dirtyPaths: [],
|
||||
},
|
||||
|
||||
@ -713,7 +713,7 @@ export class TeamChatBridge {
|
||||
const blocks = Array.isArray(response?.blocks) ? (response.blocks as MessageBlock[]) : [];
|
||||
const content =
|
||||
run.status === "failed"
|
||||
? `${target.name} could not complete the delegated request. Open Rakazo for details.`
|
||||
? `${target.name} could not complete the delegated request. Open evOo for details.`
|
||||
: teamChatResponseText(blocks, target.name);
|
||||
if (!content) continue;
|
||||
await this.deps.send({
|
||||
@ -1160,7 +1160,7 @@ export class TeamChatBridge {
|
||||
await this.sendOnce(message.id, {
|
||||
conversationId: message.externalConversation.conversationId,
|
||||
replyThreadId: message.replyThreadId,
|
||||
content: `${this.target?.name ?? "The agent"} could not complete that request. Open Rakazo for details.`,
|
||||
content: `${this.target?.name ?? "The agent"} could not complete that request. Open evOo for details.`,
|
||||
idempotencyKey: `external-message:${message.id}:failure`,
|
||||
});
|
||||
}
|
||||
|
||||
@ -88,4 +88,77 @@ describe("model team chat engagement judge", () => {
|
||||
expect(instructions).toContain("(Europe/Berlin, the user's time zone)");
|
||||
expect(instructions).toContain("low-cost engagement judge");
|
||||
});
|
||||
|
||||
it("prefers the space fast model tier when mapped and no explicit judge override is set", async () => {
|
||||
let selectedModel: { provider: string; id: string; apiKey?: string } | undefined;
|
||||
const runtime = {
|
||||
run: vi.fn((request: { model: { provider: string; id: string; apiKey?: string } }) => {
|
||||
selectedModel = request.model;
|
||||
return (async function* () {
|
||||
yield { type: "done" as const, text: '{"act":true,"reason":"Direct mention."}' };
|
||||
})();
|
||||
}),
|
||||
} as unknown as AgentRuntime;
|
||||
const prisma = {
|
||||
deploymentSettings: { findUnique: vi.fn(async () => null) },
|
||||
spaceModelPreference: { findFirst: vi.fn(async () => null) },
|
||||
spaceModelTier: {
|
||||
findUnique: vi.fn(async () => ({
|
||||
tier: "fast",
|
||||
credentialId: "cred-fast",
|
||||
modelId: "gemma4:latest",
|
||||
credential: {
|
||||
id: "cred-fast",
|
||||
userId: "user-1",
|
||||
provider: "openai-compatible",
|
||||
secretId: "sec-fast",
|
||||
preferences: [{ isDefault: false, modelId: null }],
|
||||
},
|
||||
})),
|
||||
},
|
||||
secret: {
|
||||
findFirst: vi.fn(async () => ({ id: "sec-fast", ciphertext: "cipher-fast" })),
|
||||
},
|
||||
userProfile: { findUnique: vi.fn(async () => ({ timeZone: "UTC" })) },
|
||||
} as unknown as PrismaClient;
|
||||
const secrets = {
|
||||
load: vi.fn(() =>
|
||||
JSON.stringify({
|
||||
kind: "openai_compatible",
|
||||
apiKey: "local-key",
|
||||
baseUrl: "http://127.0.0.1:11434/v1",
|
||||
}),
|
||||
),
|
||||
} as unknown as EncryptedSecretStore;
|
||||
|
||||
const judge = new ModelTeamChatEngagementJudge({
|
||||
prisma,
|
||||
runtime,
|
||||
secrets,
|
||||
deploymentProvider: "openrouter",
|
||||
deploymentModel: "expensive-default",
|
||||
});
|
||||
|
||||
const decision = await judge.decide({
|
||||
bot: {
|
||||
id: "bot-1",
|
||||
spaceId: "space-1",
|
||||
userId: "user-1",
|
||||
name: "Arthur",
|
||||
modelProvider: "anthropic",
|
||||
modelId: "claude-opus",
|
||||
},
|
||||
channelId: "C1",
|
||||
rules: "",
|
||||
messages: [],
|
||||
});
|
||||
|
||||
expect(decision).toEqual({ act: true, reason: "Direct mention.", askedByEventId: undefined });
|
||||
expect(selectedModel).toMatchObject({
|
||||
provider: "openai-compatible",
|
||||
id: "gemma4:latest",
|
||||
apiKey: "local-key",
|
||||
baseUrl: "http://127.0.0.1:11434/v1",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@ -1,14 +1,15 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import type { AgentModelOAuthCredential, AgentRuntime } from "@rakazo/adapter-kit";
|
||||
import type { EncryptedSecretStore } from "@rakazo/adapters";
|
||||
import {
|
||||
type EncryptedSecretStore,
|
||||
formatCurrentTimeInstruction,
|
||||
loadUserTimeZone,
|
||||
resolveModelAuth,
|
||||
serializeModelSecret,
|
||||
toOAuthCredential,
|
||||
} from "@rakazo/adapters";
|
||||
import { findDefaultModelCredential, findModelCredential, type PrismaClient } from "@rakazo/db";
|
||||
import type { PrismaClient } from "@rakazo/db";
|
||||
import { findDefaultModelCredential, findModelCredential, findSpaceModelTier } from "@rakazo/db";
|
||||
import { getLogger } from "@rakazo/logging";
|
||||
|
||||
const MAX_RULES_CHARS = 4_000;
|
||||
@ -191,27 +192,39 @@ export class ModelTeamChatEngagementJudge implements TeamChatEngagementJudge {
|
||||
};
|
||||
};
|
||||
} | null> {
|
||||
const settings = await this.deps.prisma.deploymentSettings.findUnique({
|
||||
where: { id: "default" },
|
||||
});
|
||||
const scope = { userId: bot.userId, spaceId: bot.spaceId };
|
||||
const defaultCredential = await findDefaultModelCredential(this.deps.prisma, scope);
|
||||
const explicitJudgeOverride = Boolean(this.deps.providerOverride && this.deps.modelOverride);
|
||||
const fastTier =
|
||||
!explicitJudgeOverride && this.deps.prisma.spaceModelTier
|
||||
? await findSpaceModelTier(this.deps.prisma, scope, "fast")
|
||||
: null;
|
||||
const defaultCredential = fastTier
|
||||
? null
|
||||
: await findDefaultModelCredential(this.deps.prisma, scope);
|
||||
const requestedProvider = this.deps.providerOverride ?? bot.modelProvider;
|
||||
const requestedModel = this.deps.modelOverride ?? bot.modelId;
|
||||
const overrideCredential = requestedProvider
|
||||
? await findModelCredential(this.deps.prisma, scope, requestedProvider)
|
||||
: null;
|
||||
const explicitJudgeOverride = Boolean(this.deps.providerOverride && this.deps.modelOverride);
|
||||
const overrideCredential =
|
||||
!fastTier && requestedProvider
|
||||
? await findModelCredential(this.deps.prisma, scope, requestedProvider)
|
||||
: null;
|
||||
const useOverride = Boolean(
|
||||
requestedProvider && requestedModel && (explicitJudgeOverride || overrideCredential),
|
||||
);
|
||||
const credential = useOverride ? overrideCredential : defaultCredential;
|
||||
const credential =
|
||||
fastTier?.credential ?? (useOverride ? overrideCredential : defaultCredential);
|
||||
const settings = fastTier
|
||||
? null
|
||||
: await this.deps.prisma.deploymentSettings.findUnique({
|
||||
where: { id: "default" },
|
||||
});
|
||||
const provider =
|
||||
fastTier?.credential.provider ??
|
||||
(useOverride ? requestedProvider : null) ??
|
||||
credential?.provider ??
|
||||
settings?.defaultModelProvider ??
|
||||
this.deps.deploymentProvider;
|
||||
const modelId =
|
||||
fastTier?.modelId ??
|
||||
(useOverride ? requestedModel : null) ??
|
||||
credential?.defaultModel ??
|
||||
settings?.defaultModelId ??
|
||||
|
||||
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;
|
||||
}
|
||||
@ -2,15 +2,17 @@ import type { SandboxProvider } from "@rakazo/adapter-kit";
|
||||
import type { Actor } from "@rakazo/contracts";
|
||||
import type { PrismaClient } from "@rakazo/db";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import type { ThreadTarget } from "./thread-target.js";
|
||||
import {
|
||||
cancelSupersededQueuedRuns,
|
||||
followUpGroupThreadMessage,
|
||||
groupDefaultBotId,
|
||||
reactToThreadMessage,
|
||||
resolveGroupFollowUpBotId,
|
||||
resumeThreadRuns,
|
||||
sendQueuedThreadMessagesNow,
|
||||
sendThreadMessage,
|
||||
stopThreadRuns,
|
||||
type ThreadTarget,
|
||||
threadHead,
|
||||
threadSnapshot,
|
||||
} from "./thread-target.js";
|
||||
@ -211,7 +213,13 @@ describe("threadSnapshot", () => {
|
||||
expect.objectContaining({
|
||||
where: expect.objectContaining({
|
||||
type: {
|
||||
in: ["thread.progress", "thread.subagent", "agent.tool.called", "agent.tool.completed"],
|
||||
in: [
|
||||
"thread.message.created",
|
||||
"thread.progress",
|
||||
"thread.subagent",
|
||||
"agent.tool.called",
|
||||
"agent.tool.completed",
|
||||
],
|
||||
},
|
||||
}),
|
||||
}),
|
||||
@ -230,6 +238,69 @@ describe("threadSnapshot", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it("does not reload live narration that was already posted as a message", async () => {
|
||||
const run = {
|
||||
id: "run-1",
|
||||
botId: "bot-1",
|
||||
threadId: "thread-1",
|
||||
taskId: "task-1",
|
||||
status: "running",
|
||||
trigger: "user",
|
||||
modelProvider: null,
|
||||
modelId: null,
|
||||
error: null,
|
||||
startedAt: null,
|
||||
completedAt: null,
|
||||
createdAt: new Date("2026-08-23T00:00:00.000Z"),
|
||||
};
|
||||
const at = new Date("2026-08-23T00:00:00.000Z");
|
||||
const event = (seq: number, type: string, payload: Record<string, unknown>) => ({
|
||||
id: `event-${seq}`,
|
||||
threadId: "thread-1",
|
||||
botId: "bot-1",
|
||||
seq,
|
||||
type,
|
||||
runId: "run-1",
|
||||
payload,
|
||||
createdAt: at,
|
||||
});
|
||||
const tx = {
|
||||
$queryRaw: threadQueries(),
|
||||
message: { findMany: vi.fn().mockResolvedValue([]) },
|
||||
event: {
|
||||
findFirst: vi.fn().mockResolvedValue({ seq: 6 }),
|
||||
findMany: vi.fn().mockResolvedValue([
|
||||
event(4, "thread.progress", { text: "Opening the page." }),
|
||||
event(5, "thread.message.created", {
|
||||
messageId: "message-1",
|
||||
role: "bot",
|
||||
blocks: [{ kind: "text", text: "Opening the page." }],
|
||||
}),
|
||||
event(6, "thread.progress", { text: "Writing the summary." }),
|
||||
]),
|
||||
},
|
||||
run: { findFirst: botRunFindFirst([run]) },
|
||||
};
|
||||
const prisma = {
|
||||
$transaction: vi.fn(async (callback: (client: typeof tx) => unknown) => callback(tx)),
|
||||
} as unknown as PrismaClient;
|
||||
const target = {
|
||||
kind: "bot",
|
||||
botId: "bot-1",
|
||||
threadId: "thread-1",
|
||||
bot: { computer: null },
|
||||
} as ThreadTarget;
|
||||
|
||||
const snapshot = await threadSnapshot({ prisma }, target);
|
||||
|
||||
expect(snapshot.messages).toEqual([
|
||||
expect.objectContaining({
|
||||
id: "progress:run-1",
|
||||
blocks: [{ kind: "progress", text: "Writing the summary." }],
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
it("returns the latest failed run so the client can show its error", async () => {
|
||||
const run = {
|
||||
id: "run-failed",
|
||||
@ -961,10 +1032,13 @@ function groupTarget() {
|
||||
}
|
||||
|
||||
describe("sendThreadMessage", () => {
|
||||
it("rejects a new bot message while a run is waiting on input", async () => {
|
||||
it("steers a waiting-input run instead of refusing the message", async () => {
|
||||
const tx = {
|
||||
thread: {
|
||||
update: vi.fn().mockResolvedValue({ nextMessageSeq: 2 }),
|
||||
update: vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce({ nextMessageSeq: 2 })
|
||||
.mockResolvedValueOnce({ nextEventSeq: 3 }),
|
||||
},
|
||||
message: {
|
||||
create: vi.fn().mockResolvedValue({
|
||||
@ -972,7 +1046,7 @@ describe("sendThreadMessage", () => {
|
||||
threadId: "thread-1",
|
||||
seq: 1,
|
||||
role: "user",
|
||||
blocks: [{ kind: "text", text: "hi" }],
|
||||
blocks: [{ kind: "text", text: "more context" }],
|
||||
botId: null,
|
||||
replyToMessageId: null,
|
||||
runId: null,
|
||||
@ -984,9 +1058,13 @@ describe("sendThreadMessage", () => {
|
||||
findMany: vi
|
||||
.fn()
|
||||
.mockResolvedValue([{ id: "run-waiting", taskId: "task-1", status: "waiting_input" }]),
|
||||
findUnique: vi.fn().mockResolvedValue({
|
||||
status: "waiting_input",
|
||||
startedAt: new Date(),
|
||||
}),
|
||||
},
|
||||
steeringMessage: { create: vi.fn() },
|
||||
event: { create: vi.fn() },
|
||||
event: { create: vi.fn().mockResolvedValue({ seq: 2 }) },
|
||||
task: { create: vi.fn() },
|
||||
};
|
||||
const prisma = {
|
||||
@ -1000,26 +1078,37 @@ describe("sendThreadMessage", () => {
|
||||
threadId: "thread-1",
|
||||
bot: { computer: null },
|
||||
} as ThreadTarget;
|
||||
const enqueue = vi.fn().mockResolvedValue(undefined);
|
||||
|
||||
await expect(
|
||||
sendThreadMessage(
|
||||
{
|
||||
prisma,
|
||||
events: { notify: vi.fn() } as never,
|
||||
jobs: { enqueue: vi.fn() } as never,
|
||||
events: { notify: vi.fn().mockResolvedValue(undefined) } as never,
|
||||
jobs: { enqueue } as never,
|
||||
},
|
||||
actor,
|
||||
target,
|
||||
{
|
||||
text: "hi",
|
||||
text: "more context",
|
||||
clientNonce: "nonce-1",
|
||||
},
|
||||
),
|
||||
).rejects.toMatchObject({
|
||||
code: "CONFLICT",
|
||||
message: "Answer the pending ask first.",
|
||||
).resolves.toMatchObject({
|
||||
runId: "run-waiting",
|
||||
taskId: "task-1",
|
||||
seq: 1,
|
||||
runIds: ["run-waiting"],
|
||||
});
|
||||
expect(tx.steeringMessage.create).not.toHaveBeenCalled();
|
||||
expect(tx.steeringMessage.create).toHaveBeenCalledWith({
|
||||
data: {
|
||||
messageId: "msg-1",
|
||||
botId: "bot-1",
|
||||
userId: "user-1",
|
||||
runId: "run-waiting",
|
||||
},
|
||||
});
|
||||
expect(enqueue).toHaveBeenCalledWith(expect.objectContaining({ name: "run.continue" }));
|
||||
});
|
||||
|
||||
it("steers a waiting-takeover run instead of refusing the message", async () => {
|
||||
@ -2031,4 +2120,192 @@ describe("stopThreadRuns", () => {
|
||||
expect.objectContaining({ runId: "run-b" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("sendQueuedThreadMessagesNow cancels the active run, preserves pending steering, and enqueues an immediate continuation run", async () => {
|
||||
const queuedCreatedAt = new Date("2026-01-01T10:00:05.000Z");
|
||||
const transaction = {
|
||||
$queryRaw: vi.fn(),
|
||||
steeringMessage: {
|
||||
findMany: vi.fn().mockResolvedValue([
|
||||
{
|
||||
id: "steer-1",
|
||||
botId: "bot-a",
|
||||
userId: "user-1",
|
||||
messageId: "msg-queued-1",
|
||||
claimedAt: null,
|
||||
message: {
|
||||
id: "msg-queued-1",
|
||||
blocks: [{ kind: "text", text: "Send this right now" }],
|
||||
seq: 2,
|
||||
createdAt: queuedCreatedAt,
|
||||
},
|
||||
},
|
||||
]),
|
||||
deleteMany: vi.fn().mockResolvedValue({ count: 0 }),
|
||||
updateMany: vi.fn().mockResolvedValue({ count: 1 }),
|
||||
},
|
||||
run: {
|
||||
updateManyAndReturn: vi.fn().mockResolvedValue([{ id: "run-old", taskId: "task-old" }]),
|
||||
create: vi.fn().mockResolvedValue({ id: "run-new", status: "queued" }),
|
||||
},
|
||||
task: {
|
||||
updateMany: vi.fn().mockResolvedValue({ count: 1 }),
|
||||
create: vi.fn().mockResolvedValue({ id: "task-new" }),
|
||||
},
|
||||
message: {
|
||||
updateMany: vi.fn().mockResolvedValue({ count: 1 }),
|
||||
},
|
||||
computerExecutionLease: {
|
||||
findMany: vi.fn().mockResolvedValue([]),
|
||||
},
|
||||
computer: {
|
||||
findMany: vi.fn().mockResolvedValue([]),
|
||||
},
|
||||
event: {
|
||||
findFirst: vi.fn().mockResolvedValue({ seq: 42 }),
|
||||
},
|
||||
};
|
||||
const prisma = {
|
||||
$transaction: vi.fn(async (callback: (client: typeof transaction) => unknown) =>
|
||||
callback(transaction),
|
||||
),
|
||||
computerExecutionLease: {
|
||||
updateMany: vi.fn().mockResolvedValue({ count: 0 }),
|
||||
},
|
||||
computer: {
|
||||
updateMany: vi.fn().mockResolvedValue({ count: 0 }),
|
||||
},
|
||||
event: {
|
||||
deleteMany: vi.fn().mockResolvedValue({ count: 0 }),
|
||||
},
|
||||
} as unknown as PrismaClient;
|
||||
const jobs = {
|
||||
enqueue: vi.fn().mockResolvedValue(undefined),
|
||||
cancel: vi.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
const events = {
|
||||
notify: vi.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
const actor = { spaceId: "workspace-1", userId: "user-1" } as Actor;
|
||||
const target = {
|
||||
kind: "bot",
|
||||
botId: "bot-a",
|
||||
threadId: "thread-1",
|
||||
bot: {} as never,
|
||||
} satisfies ThreadTarget;
|
||||
|
||||
const result = await sendQueuedThreadMessagesNow(
|
||||
{
|
||||
prisma,
|
||||
sandbox: {} as SandboxProvider,
|
||||
events: events as never,
|
||||
jobs: jobs as never,
|
||||
},
|
||||
actor,
|
||||
target,
|
||||
{ messageId: "msg-queued-1" },
|
||||
);
|
||||
|
||||
expect(result).toEqual({ ok: true, runIds: ["run-new"] });
|
||||
expect(transaction.run.updateManyAndReturn).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
where: expect.objectContaining({
|
||||
threadId: "thread-1",
|
||||
botId: { in: ["bot-a"] },
|
||||
}),
|
||||
data: expect.objectContaining({ status: "cancelled" }),
|
||||
}),
|
||||
);
|
||||
expect(transaction.steeringMessage.updateMany).toHaveBeenCalledWith({
|
||||
where: { id: { in: ["steer-1"] } },
|
||||
data: { runId: "run-new", claimedAt: null },
|
||||
});
|
||||
expect(jobs.cancel).toHaveBeenCalledWith("run:run-old");
|
||||
expect(jobs.enqueue).toHaveBeenCalledWith({
|
||||
name: "run.continue",
|
||||
payload: { runId: "run-new" },
|
||||
replaceKey: "run:run-new",
|
||||
});
|
||||
expect(events.notify).toHaveBeenCalledWith("thread-1", 42);
|
||||
});
|
||||
});
|
||||
|
||||
describe("resumeThreadRuns", () => {
|
||||
it("re-queues the latest cancelled or failed run without creating a duplicate message", async () => {
|
||||
const transaction = {
|
||||
$queryRaw: vi.fn().mockResolvedValue([{ id: "thread-1" }]),
|
||||
run: {
|
||||
findMany: vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce([])
|
||||
.mockResolvedValueOnce([
|
||||
{
|
||||
id: "run-cancelled",
|
||||
taskId: "task-cancelled",
|
||||
botId: "bot-a",
|
||||
status: "cancelled",
|
||||
createdAt: new Date("2026-04-20T10:00:00.000Z"),
|
||||
completedAt: new Date("2026-04-20T10:02:00.000Z"),
|
||||
},
|
||||
]),
|
||||
update: vi.fn().mockResolvedValue({}),
|
||||
},
|
||||
task: {
|
||||
updateMany: vi.fn().mockResolvedValue({ count: 1 }),
|
||||
},
|
||||
event: {
|
||||
findFirst: vi.fn().mockResolvedValue({ seq: 19 }),
|
||||
},
|
||||
};
|
||||
const prisma = {
|
||||
$transaction: vi.fn(async (fn: (tx: typeof transaction) => Promise<unknown>) =>
|
||||
fn(transaction),
|
||||
),
|
||||
} as unknown as PrismaClient;
|
||||
const jobs = {
|
||||
enqueue: vi.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
const events = {
|
||||
notify: vi.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
const actor = { spaceId: "workspace-1", userId: "user-1" } as Actor;
|
||||
const target = {
|
||||
kind: "bot",
|
||||
botId: "bot-a",
|
||||
threadId: "thread-1",
|
||||
bot: {} as never,
|
||||
} satisfies ThreadTarget;
|
||||
|
||||
const result = await resumeThreadRuns(
|
||||
{
|
||||
prisma,
|
||||
events: events as never,
|
||||
jobs: jobs as never,
|
||||
},
|
||||
actor,
|
||||
target,
|
||||
);
|
||||
|
||||
expect(result).toEqual({ ok: true, runIds: ["run-cancelled"] });
|
||||
expect(transaction.task.updateMany).toHaveBeenCalledWith({
|
||||
where: { id: "task-cancelled" },
|
||||
data: { status: "queued" },
|
||||
});
|
||||
expect(transaction.run.update).toHaveBeenCalledWith({
|
||||
where: { id: "run-cancelled" },
|
||||
data: {
|
||||
status: "queued",
|
||||
error: null,
|
||||
completedAt: null,
|
||||
leaseOwner: null,
|
||||
leaseExpiresAt: null,
|
||||
},
|
||||
});
|
||||
expect(jobs.enqueue).toHaveBeenCalledWith({
|
||||
name: "run.continue",
|
||||
payload: { runId: "run-cancelled" },
|
||||
replaceKey: "run:run-cancelled",
|
||||
});
|
||||
expect(events.notify).toHaveBeenCalledWith("thread-1", 19);
|
||||
});
|
||||
});
|
||||
|
||||
@ -1,5 +1,10 @@
|
||||
import { ORPCError } from "@orpc/server";
|
||||
import { type JobPublisher, runContinueJob, type SandboxProvider } from "@rakazo/adapter-kit";
|
||||
import {
|
||||
type JobPublisher,
|
||||
runContinueJob,
|
||||
runJobKey,
|
||||
type SandboxProvider,
|
||||
} from "@rakazo/adapter-kit";
|
||||
import { cancelComputerRunWork, screenLeaseIdForRun, toComputerRef } from "@rakazo/adapters";
|
||||
import {
|
||||
type Actor,
|
||||
@ -160,9 +165,15 @@ function quoteAppearsInBlocks(quote: string, blocks: MessageBlock[]): boolean {
|
||||
}
|
||||
|
||||
const THREAD_MESSAGE_PAGE_SIZE = 100;
|
||||
const RUNS_NEEDING_CONTINUE = new Set(["queued", "waiting_takeover"]);
|
||||
const RUNS_NEEDING_CONTINUE = new Set(["queued", "waiting_input", "waiting_takeover"]);
|
||||
|
||||
const STEERABLE_RUN_STATUSES = new Set(["queued", "leased", "running", "waiting_takeover"]);
|
||||
const STEERABLE_RUN_STATUSES = new Set([
|
||||
"queued",
|
||||
"leased",
|
||||
"running",
|
||||
"waiting_input",
|
||||
"waiting_takeover",
|
||||
]);
|
||||
|
||||
type MentionTargetInput = string | { kind: "bot" | "group" | "routine" | "connector"; id: string };
|
||||
|
||||
@ -491,6 +502,7 @@ export async function threadSnapshot(
|
||||
runId: currentRun.id,
|
||||
type: {
|
||||
in: [
|
||||
"thread.message.created",
|
||||
"thread.progress",
|
||||
"thread.subagent",
|
||||
"agent.tool.called",
|
||||
@ -556,6 +568,7 @@ export async function threadSnapshot(
|
||||
runId: { in: activeRuns.map((run) => run.id) },
|
||||
type: {
|
||||
in: [
|
||||
"thread.message.created",
|
||||
"thread.progress",
|
||||
"thread.subagent",
|
||||
"agent.tool.called",
|
||||
@ -631,8 +644,12 @@ function messagesWithLiveEvents(
|
||||
persisted: ThreadSnapshot["messages"],
|
||||
liveEvents: Parameters<typeof projectMessages>[0],
|
||||
) {
|
||||
// Message events only clear the live text they promoted; the persisted rows already carry them.
|
||||
const live = projectMessages(liveEvents).filter((message) => {
|
||||
if (message.blocks.some((block) => block.kind === "progress" || block.kind === "steps")) {
|
||||
if (
|
||||
message.id.startsWith("progress:") &&
|
||||
message.blocks.some((block) => block.kind === "progress" || block.kind === "steps")
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
if (!message.id.startsWith("subagent:")) return false;
|
||||
@ -1139,63 +1156,50 @@ export async function reactToThreadMessage(
|
||||
});
|
||||
}
|
||||
|
||||
export async function stopThreadRuns(
|
||||
async function snapshotCancelledRunTeardown(tx: Prisma.TransactionClient, runIds: string[]) {
|
||||
// Snapshot teardown coordinates before commit. Once cancellation becomes
|
||||
// visible, a worker can release its lease / execution columns immediately; a
|
||||
// later lookup would then miss the sandbox work this request must stop.
|
||||
// Team ownership lives on ComputerExecutionLease; Computer.executionRunId is
|
||||
// only a legacy secondary path and is not written by current acquisition.
|
||||
const leases = runIds.length
|
||||
? await tx.computerExecutionLease.findMany({
|
||||
where: { runId: { in: runIds } },
|
||||
select: { computerId: true, botId: true, runId: true, fence: true },
|
||||
})
|
||||
: [];
|
||||
const leaseComputerIds = [...new Set(leases.map((lease) => lease.computerId))];
|
||||
const computers = runIds.length
|
||||
? await tx.computer.findMany({
|
||||
where:
|
||||
leaseComputerIds.length > 0
|
||||
? {
|
||||
OR: [{ id: { in: leaseComputerIds } }, { executionRunId: { in: runIds } }],
|
||||
}
|
||||
: { executionRunId: { in: runIds } },
|
||||
select: {
|
||||
id: true,
|
||||
homeKey: true,
|
||||
kind: true,
|
||||
providerRef: true,
|
||||
executionBotId: true,
|
||||
executionRunId: true,
|
||||
},
|
||||
})
|
||||
: [];
|
||||
return { runIds, computers, leases };
|
||||
}
|
||||
|
||||
async function teardownCancelledRuns(
|
||||
deps: {
|
||||
prisma: PrismaClient;
|
||||
sandbox: SandboxProvider;
|
||||
},
|
||||
actor: Actor,
|
||||
target: ThreadTarget,
|
||||
snapshot: Awaited<ReturnType<typeof snapshotCancelledRunTeardown>>,
|
||||
) {
|
||||
const { runIds, computers, leases } = await deps.prisma.$transaction(async (tx) => {
|
||||
await tx.$queryRaw`SELECT id FROM threads WHERE id = ${target.threadId} FOR UPDATE`;
|
||||
const cancelled = await tx.run.updateManyAndReturn({
|
||||
where: {
|
||||
threadId: target.threadId,
|
||||
status: { in: [...ACTIVE_RUN_STATUSES] },
|
||||
},
|
||||
data: { status: "cancelled", completedAt: new Date() },
|
||||
select: { id: true },
|
||||
});
|
||||
const ids = cancelled.map((run) => run.id);
|
||||
await tx.steeringMessage.deleteMany({
|
||||
where: {
|
||||
botId: { in: target.kind === "bot" ? [target.botId] : target.memberBotIds },
|
||||
message: { threadId: target.threadId },
|
||||
},
|
||||
});
|
||||
// Snapshot teardown coordinates before commit. Once cancellation becomes
|
||||
// visible, a worker can release its lease / execution columns immediately; a
|
||||
// later lookup would then miss the sandbox work this request must stop.
|
||||
// Team ownership lives on ComputerExecutionLease; Computer.executionRunId is
|
||||
// only a legacy secondary path and is not written by current acquisition.
|
||||
const leases = ids.length
|
||||
? await tx.computerExecutionLease.findMany({
|
||||
where: { runId: { in: ids } },
|
||||
select: { computerId: true, botId: true, runId: true, fence: true },
|
||||
})
|
||||
: [];
|
||||
const leaseComputerIds = [...new Set(leases.map((lease) => lease.computerId))];
|
||||
const computers = ids.length
|
||||
? await tx.computer.findMany({
|
||||
where:
|
||||
leaseComputerIds.length > 0
|
||||
? {
|
||||
OR: [{ id: { in: leaseComputerIds } }, { executionRunId: { in: ids } }],
|
||||
}
|
||||
: { executionRunId: { in: ids } },
|
||||
select: {
|
||||
id: true,
|
||||
homeKey: true,
|
||||
kind: true,
|
||||
providerRef: true,
|
||||
executionBotId: true,
|
||||
executionRunId: true,
|
||||
},
|
||||
})
|
||||
: [];
|
||||
return { runIds: ids, computers, leases };
|
||||
});
|
||||
const { runIds, computers, leases } = snapshot;
|
||||
if (runIds.length === 0) return;
|
||||
// Keep the DB lease until after teardown so a replacement run cannot claim the
|
||||
// screen while we still need the cancelled run's screenLeaseId to release it.
|
||||
const computerById = new Map(computers.map((computer) => [computer.id, computer]));
|
||||
@ -1273,6 +1277,381 @@ export async function stopThreadRuns(
|
||||
});
|
||||
}
|
||||
|
||||
export async function stopThreadRuns(
|
||||
deps: {
|
||||
prisma: PrismaClient;
|
||||
sandbox: SandboxProvider;
|
||||
},
|
||||
actor: Actor,
|
||||
target: ThreadTarget,
|
||||
) {
|
||||
const snapshot = await deps.prisma.$transaction(async (tx) => {
|
||||
await tx.$queryRaw`SELECT id FROM threads WHERE id = ${target.threadId} FOR UPDATE`;
|
||||
const cancelled = await tx.run.updateManyAndReturn({
|
||||
where: {
|
||||
threadId: target.threadId,
|
||||
status: { in: [...ACTIVE_RUN_STATUSES] },
|
||||
},
|
||||
data: { status: "cancelled", completedAt: new Date() },
|
||||
select: { id: true },
|
||||
});
|
||||
const ids = cancelled.map((run) => run.id);
|
||||
await tx.steeringMessage.deleteMany({
|
||||
where: {
|
||||
botId: { in: target.kind === "bot" ? [target.botId] : target.memberBotIds },
|
||||
message: { threadId: target.threadId },
|
||||
},
|
||||
});
|
||||
return snapshotCancelledRunTeardown(tx, ids);
|
||||
});
|
||||
await teardownCancelledRuns(deps, actor, snapshot);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resume the latest stopped (`cancelled`) or interrupted (`failed`) run in the thread
|
||||
* without creating a duplicate user message row.
|
||||
*/
|
||||
export async function resumeThreadRuns(
|
||||
deps: {
|
||||
prisma: PrismaClient;
|
||||
events: ThreadEvents;
|
||||
jobs: JobPublisher;
|
||||
},
|
||||
actor: Actor,
|
||||
target: ThreadTarget,
|
||||
) {
|
||||
const botIds = target.kind === "bot" ? [target.botId] : target.memberBotIds;
|
||||
const committed = await deps.prisma.$transaction(async (tx) => {
|
||||
await tx.$queryRaw`SELECT id FROM threads WHERE id = ${target.threadId} FOR UPDATE`;
|
||||
const activeRuns = await tx.run.findMany({
|
||||
where: {
|
||||
threadId: target.threadId,
|
||||
botId: { in: botIds },
|
||||
status: { in: [...ACTIVE_RUN_STATUSES] },
|
||||
},
|
||||
select: { id: true, status: true },
|
||||
});
|
||||
if (activeRuns.length > 0) {
|
||||
return {
|
||||
runs: activeRuns,
|
||||
eventSeq: null as number | null,
|
||||
};
|
||||
}
|
||||
|
||||
const recentTerminals = await tx.run.findMany({
|
||||
where: {
|
||||
threadId: target.threadId,
|
||||
botId: { in: botIds },
|
||||
trigger: { not: "bot_message" },
|
||||
status: { in: ["failed", "completed", "cancelled"] },
|
||||
},
|
||||
orderBy: [{ updatedAt: "desc" }, { id: "desc" }],
|
||||
take: 20,
|
||||
select: {
|
||||
id: true,
|
||||
botId: true,
|
||||
taskId: true,
|
||||
status: true,
|
||||
createdAt: true,
|
||||
completedAt: true,
|
||||
},
|
||||
});
|
||||
const latestTerminal = pickLatestTerminalRun(recentTerminals);
|
||||
if (
|
||||
!latestTerminal ||
|
||||
(latestTerminal.status !== "cancelled" && latestTerminal.status !== "failed")
|
||||
) {
|
||||
return {
|
||||
runs: [] as Array<{ id: string; status: string }>,
|
||||
eventSeq: null as number | null,
|
||||
};
|
||||
}
|
||||
|
||||
await tx.task.updateMany({
|
||||
where: { id: latestTerminal.taskId },
|
||||
data: { status: "queued" },
|
||||
});
|
||||
await tx.run.update({
|
||||
where: { id: latestTerminal.id },
|
||||
data: {
|
||||
status: "queued",
|
||||
error: null,
|
||||
completedAt: null,
|
||||
leaseOwner: null,
|
||||
leaseExpiresAt: null,
|
||||
},
|
||||
});
|
||||
if (target.kind === "group") {
|
||||
await touchGroupUpdatedAt(tx, target.groupId);
|
||||
}
|
||||
const latestEvent = await tx.event.findFirst({
|
||||
where: { threadId: target.threadId },
|
||||
orderBy: { seq: "desc" },
|
||||
select: { seq: true },
|
||||
});
|
||||
return {
|
||||
runs: [{ id: latestTerminal.id, status: "queued" }],
|
||||
eventSeq: latestEvent?.seq ?? null,
|
||||
};
|
||||
});
|
||||
|
||||
if (committed.eventSeq !== null) {
|
||||
await deps.events.notify(target.threadId, committed.eventSeq).catch((error) => {
|
||||
getLogger().error("thread resume realtime notification", error);
|
||||
});
|
||||
}
|
||||
await enqueueRunsNeedingContinue(deps.jobs, committed.runs);
|
||||
return {
|
||||
ok: true as const,
|
||||
runIds: committed.runs.map((run) => run.id),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Promote queued follow-up messages immediately: cancel any currently active runs
|
||||
* holding up the queue without discarding unclaimed steering messages, create a
|
||||
* new continuation run for the pending steering messages, and enqueue it right away.
|
||||
*/
|
||||
export async function sendQueuedThreadMessagesNow(
|
||||
deps: {
|
||||
prisma: PrismaClient;
|
||||
sandbox: SandboxProvider;
|
||||
events: ThreadEvents;
|
||||
jobs: JobPublisher;
|
||||
},
|
||||
actor: Actor,
|
||||
target: ThreadTarget,
|
||||
input?: { messageId?: string },
|
||||
) {
|
||||
const botIds = target.kind === "bot" ? [target.botId] : target.memberBotIds;
|
||||
const committed = await deps.prisma.$transaction(async (tx) => {
|
||||
await tx.$queryRaw`SELECT id FROM threads WHERE id = ${target.threadId} FOR UPDATE`;
|
||||
const pendingSteering = await tx.steeringMessage.findMany({
|
||||
where: {
|
||||
botId: { in: botIds },
|
||||
claimedAt: null,
|
||||
message: { threadId: target.threadId },
|
||||
},
|
||||
include: {
|
||||
message: { select: { id: true, blocks: true, seq: true, createdAt: true } },
|
||||
},
|
||||
orderBy: [{ message: { seq: "asc" } }, { id: "asc" }],
|
||||
});
|
||||
|
||||
const pendingByBotId = new Map<string, typeof pendingSteering>();
|
||||
for (const item of pendingSteering) {
|
||||
const list = pendingByBotId.get(item.botId) ?? [];
|
||||
list.push(item);
|
||||
pendingByBotId.set(item.botId, list);
|
||||
}
|
||||
|
||||
if (pendingByBotId.size === 0 && input?.messageId) {
|
||||
const targetMsg = await tx.message.findFirst({
|
||||
where: { id: input.messageId, threadId: target.threadId, role: "user" },
|
||||
select: { id: true, blocks: true, seq: true, createdAt: true },
|
||||
});
|
||||
if (targetMsg) {
|
||||
const activeRuns = await tx.run.findMany({
|
||||
where: {
|
||||
threadId: target.threadId,
|
||||
botId: { in: botIds },
|
||||
status: { in: [...ACTIVE_RUN_STATUSES] },
|
||||
createdAt: { lt: targetMsg.createdAt },
|
||||
},
|
||||
select: { id: true, botId: true },
|
||||
});
|
||||
const fallbackBotId =
|
||||
activeRuns[0]?.botId ??
|
||||
(target.kind === "bot" ? target.botId : groupDefaultBotId(target));
|
||||
if (fallbackBotId && activeRuns.length > 0) {
|
||||
const steering = await tx.steeringMessage.create({
|
||||
data: {
|
||||
messageId: targetMsg.id,
|
||||
botId: fallbackBotId,
|
||||
userId: actor.userId,
|
||||
runId: activeRuns[0]?.id ?? null,
|
||||
},
|
||||
});
|
||||
pendingByBotId.set(fallbackBotId, [{ ...steering, message: targetMsg }]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (pendingByBotId.size === 0) {
|
||||
return {
|
||||
teardown: { runIds: [] as string[], computers: [], leases: [] },
|
||||
runs: [] as Array<{ id: string; status: string }>,
|
||||
eventSeq: null as number | null,
|
||||
};
|
||||
}
|
||||
|
||||
const affectedBotIds = [...pendingByBotId.keys()];
|
||||
const now = new Date();
|
||||
const cancelled = await tx.run.updateManyAndReturn({
|
||||
where: {
|
||||
threadId: target.threadId,
|
||||
botId: { in: affectedBotIds },
|
||||
status: { in: [...ACTIVE_RUN_STATUSES] },
|
||||
},
|
||||
data: { status: "cancelled", completedAt: now },
|
||||
select: { id: true, taskId: true },
|
||||
});
|
||||
const cancelledRunIds = cancelled.map((run) => run.id);
|
||||
if (cancelled.length > 0) {
|
||||
await tx.task.updateMany({
|
||||
where: { id: { in: cancelled.map((run) => run.taskId) } },
|
||||
data: { status: "cancelled" },
|
||||
});
|
||||
await tx.steeringMessage.deleteMany({
|
||||
where: {
|
||||
runId: { in: cancelledRunIds },
|
||||
claimedAt: { not: null },
|
||||
},
|
||||
});
|
||||
}
|
||||
const teardown = await snapshotCancelledRunTeardown(tx, cancelledRunIds);
|
||||
|
||||
const createdRuns: Array<{ id: string; status: string }> = [];
|
||||
for (const [botId, items] of pendingByBotId) {
|
||||
const last = items.at(-1)!;
|
||||
const task = await tx.task.create({
|
||||
data: {
|
||||
spaceId: actor.spaceId,
|
||||
botId,
|
||||
threadId: target.threadId,
|
||||
userId: items[0]!.userId,
|
||||
prompt: "Respond to the user's steering context.",
|
||||
status: "queued",
|
||||
},
|
||||
});
|
||||
const runCreatedAt = new Date(Math.max(Date.now(), last.message.createdAt.getTime() + 1));
|
||||
const run = await tx.run.create({
|
||||
data: {
|
||||
spaceId: actor.spaceId,
|
||||
botId,
|
||||
threadId: target.threadId,
|
||||
taskId: task.id,
|
||||
userId: items[0]!.userId,
|
||||
status: "queued",
|
||||
trigger: "follow_up",
|
||||
sourceMessageId: last.message.id,
|
||||
createdAt: runCreatedAt,
|
||||
},
|
||||
select: { id: true, status: true },
|
||||
});
|
||||
await tx.steeringMessage.updateMany({
|
||||
where: { id: { in: items.map((item) => item.id) } },
|
||||
data: { runId: run.id, claimedAt: null },
|
||||
});
|
||||
await tx.message.updateMany({
|
||||
where: { id: { in: items.map((item) => item.message.id) } },
|
||||
data: { runId: run.id },
|
||||
});
|
||||
createdRuns.push(run);
|
||||
}
|
||||
|
||||
if (target.kind === "group") {
|
||||
await touchGroupUpdatedAt(tx, target.groupId);
|
||||
}
|
||||
const latestEvent = await tx.event.findFirst({
|
||||
where: { threadId: target.threadId },
|
||||
orderBy: { seq: "desc" },
|
||||
select: { seq: true },
|
||||
});
|
||||
return {
|
||||
teardown,
|
||||
runs: createdRuns,
|
||||
eventSeq: latestEvent?.seq ?? null,
|
||||
};
|
||||
});
|
||||
|
||||
if (committed.teardown.runIds.length > 0) {
|
||||
await Promise.all([
|
||||
teardownCancelledRuns(deps, actor, committed.teardown),
|
||||
...committed.teardown.runIds.map((runId) =>
|
||||
deps.jobs.cancel(runJobKey(runId)).catch(() => undefined),
|
||||
),
|
||||
]);
|
||||
}
|
||||
if (committed.runs.length > 0) {
|
||||
await enqueueRunsNeedingContinue(deps.jobs, committed.runs);
|
||||
if (committed.eventSeq != null) {
|
||||
await deps.events.notify(target.threadId, committed.eventSeq).catch((error) => {
|
||||
getLogger().error("thread sendNow realtime notification", error);
|
||||
});
|
||||
}
|
||||
}
|
||||
return {
|
||||
ok: true as const,
|
||||
runIds: committed.runs.map((run) => run.id),
|
||||
};
|
||||
}
|
||||
|
||||
export async function removeQueuedMessage(
|
||||
deps: {
|
||||
prisma: PrismaClient;
|
||||
events: ThreadEvents;
|
||||
},
|
||||
actor: Actor,
|
||||
target: ThreadTarget,
|
||||
messageId: string,
|
||||
) {
|
||||
const botIds = target.kind === "bot" ? [target.botId] : target.memberBotIds;
|
||||
const result = await deps.prisma.$transaction(async (tx) => {
|
||||
// Verify the message exists and belongs to this thread
|
||||
const message = await tx.message.findFirst({
|
||||
where: {
|
||||
id: messageId,
|
||||
threadId: target.threadId,
|
||||
role: "user",
|
||||
},
|
||||
});
|
||||
if (!message) {
|
||||
return { removed: false as const };
|
||||
}
|
||||
|
||||
// Check if this message is queued (has an unclaimed steering message)
|
||||
const steering = await tx.steeringMessage.findFirst({
|
||||
where: {
|
||||
messageId: messageId,
|
||||
botId: { in: botIds },
|
||||
claimedAt: null,
|
||||
},
|
||||
});
|
||||
|
||||
if (!steering) {
|
||||
// No queued steering entry — nothing to remove
|
||||
return { removed: false as const };
|
||||
}
|
||||
|
||||
// Delete the steering message (unclaims it from the queue)
|
||||
await tx.steeringMessage.delete({
|
||||
where: { id: steering.id },
|
||||
});
|
||||
|
||||
// Also clean up the user message if it's exclusively queued
|
||||
// (no run attached to it)
|
||||
if (!message.runId) {
|
||||
await tx.message.delete({
|
||||
where: { id: messageId },
|
||||
});
|
||||
}
|
||||
|
||||
return { removed: true as const };
|
||||
});
|
||||
|
||||
if (result.removed) {
|
||||
// Notify subscribers so the UI updates
|
||||
const latest = await deps.prisma.event.findFirst({
|
||||
where: { threadId: target.threadId },
|
||||
orderBy: { seq: "desc" },
|
||||
select: { seq: true },
|
||||
});
|
||||
await deps.events.notify(target.threadId, latest?.seq ?? 0).catch(() => undefined);
|
||||
}
|
||||
|
||||
return { ok: true as const, removed: result.removed };
|
||||
}
|
||||
export async function setThreadUnreadState(
|
||||
prisma: PrismaClient,
|
||||
actor: Actor,
|
||||
|
||||
@ -13,18 +13,18 @@ import {
|
||||
|
||||
describe("toVoiceStatus", () => {
|
||||
it("treats a saved key without a voice as configured but not ready", () => {
|
||||
expect(toVoiceStatus({ provider: "elevenlabs", voiceId: "" })).toEqual({
|
||||
expect(toVoiceStatus({ provider: "kokoro", voiceId: "" })).toEqual({
|
||||
configured: true,
|
||||
ready: false,
|
||||
transcribe: true,
|
||||
provider: "elevenlabs",
|
||||
transcribe: false,
|
||||
provider: "kokoro",
|
||||
voiceId: "",
|
||||
});
|
||||
});
|
||||
|
||||
it("is ready once a voice is chosen", () => {
|
||||
expect(toVoiceStatus({ provider: "cartesia", voiceId: "katie" }).ready).toBe(true);
|
||||
expect(toVoiceStatus({ provider: "cartesia", voiceId: "katie" }).transcribe).toBe(false);
|
||||
expect(toVoiceStatus({ provider: "kokoro", voiceId: "df_eva" }).ready).toBe(true);
|
||||
expect(toVoiceStatus({ provider: "kokoro", voiceId: "df_eva" }).transcribe).toBe(false);
|
||||
});
|
||||
|
||||
it("is off when nothing is connected", () => {
|
||||
@ -39,7 +39,7 @@ describe("toVoiceStatus", () => {
|
||||
});
|
||||
|
||||
describe("voice HTTP routes", () => {
|
||||
it("rejects unauthenticated speak and transcribe", async () => {
|
||||
it("rejects unauthenticated speak, transcribe, and transcribe-stream", async () => {
|
||||
const app = new Hono();
|
||||
mountVoiceHttpRoutes(app, {} as VoiceDeps, async () => null);
|
||||
const speak = await app.request("/api/voice/speak", {
|
||||
@ -52,13 +52,20 @@ describe("voice HTTP routes", () => {
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ audioBase64: "AAAA", mimeType: "audio/webm" }),
|
||||
});
|
||||
const transcribeStream = await app.request("/api/voice/transcribe-stream", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ audioBase64: "AAAA", mimeType: "audio/webm" }),
|
||||
});
|
||||
expect(speak.status).toBe(401);
|
||||
expect(transcribe.status).toBe(401);
|
||||
expect(transcribeStream.status).toBe(401);
|
||||
});
|
||||
|
||||
it.each([
|
||||
["/api/voice/speak", MAX_SPEAK_REQUEST_BYTES],
|
||||
["/api/voice/transcribe", MAX_TRANSCRIBE_REQUEST_BYTES],
|
||||
["/api/voice/transcribe-stream", MAX_TRANSCRIBE_REQUEST_BYTES],
|
||||
])(
|
||||
"rejects a declared oversized body on %s without waiting for cancellation",
|
||||
async (path, max) => {
|
||||
@ -112,6 +119,22 @@ describe("voice HTTP routes", () => {
|
||||
expect(response.status).toBe(413);
|
||||
expect(cancel).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("rejects empty recording on transcribe-stream", async () => {
|
||||
const app = new Hono();
|
||||
mountVoiceHttpRoutes(
|
||||
app,
|
||||
{} as VoiceDeps,
|
||||
async () => ({ userId: "user", spaceId: "space" }) as Actor,
|
||||
);
|
||||
const response = await app.request("/api/voice/transcribe-stream", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ audioBase64: "", mimeType: "audio/webm" }),
|
||||
});
|
||||
expect(response.status).toBe(400);
|
||||
await expect(response.json()).resolves.toEqual({ error: "Recording is empty." });
|
||||
});
|
||||
});
|
||||
|
||||
const actor = { userId: "user-1", spaceId: "space-1" } as Actor;
|
||||
|
||||
@ -25,7 +25,7 @@ import {
|
||||
selectSpaceVoicePreference,
|
||||
} from "@rakazo/db";
|
||||
import type { Context, Hono } from "hono";
|
||||
import { readBoundedBody } from "./http-body.js";
|
||||
import { cancelBody, readBoundedBody } from "./http-body.js";
|
||||
import { withSerializableRetry } from "./serializable-retry.js";
|
||||
|
||||
export interface VoiceDeps {
|
||||
@ -96,7 +96,8 @@ export async function loadVoiceCredential(deps: VoiceDeps, actor: Actor, provide
|
||||
const cred = provider
|
||||
? await findVoiceCredential(deps.prisma, actor, provider)
|
||||
: await findDefaultVoiceCredential(deps.prisma, actor);
|
||||
if (!cred) return null;
|
||||
// Credentials of providers that were removed from the catalog no longer speak.
|
||||
if (!cred || !isVoiceProviderId(cred.provider)) return null;
|
||||
const secret = await deps.prisma.secret.findFirst({
|
||||
where: { id: cred.secretId, userId: actor.userId, spaceId: null },
|
||||
});
|
||||
@ -146,7 +147,11 @@ export async function persistVoiceCredential(
|
||||
let voiceId = input.voiceId?.trim() ?? "";
|
||||
if (!voiceId) {
|
||||
const voices = await provider.listVoices(input.plaintext, voiceContext(actor, input.signal));
|
||||
voiceId = voices[0]?.id ?? "";
|
||||
const germanVoice =
|
||||
input.provider === "kokoro"
|
||||
? voices.find((v) => v.id === "df_eva" || /^d[fm]_/i.test(v.id))
|
||||
: undefined;
|
||||
voiceId = germanVoice?.id ?? voices[0]?.id ?? "";
|
||||
}
|
||||
const stored = await deps.secrets.put(input.plaintext, voiceContext(actor, input.signal));
|
||||
const cred = await withSerializableRetry(() =>
|
||||
@ -375,6 +380,113 @@ export function mountVoiceHttpRoutes(
|
||||
return voiceHttpError(c, error);
|
||||
}
|
||||
});
|
||||
|
||||
app.post("/api/voice/transcribe-stream", async (c) => {
|
||||
const actor = await authenticate(c);
|
||||
if (!actor) return c.json({ error: "Unauthorized" }, 401);
|
||||
const contentType = c.req.header("content-type") ?? "";
|
||||
const isJson =
|
||||
contentType.includes("application/json") ||
|
||||
(!contentType.startsWith("audio/") && !contentType.includes("octet-stream"));
|
||||
|
||||
let audio: Uint8Array;
|
||||
let mimeType = "audio/webm";
|
||||
|
||||
if (isJson) {
|
||||
const raw = await readBoundedBody(c.req.raw, MAX_TRANSCRIBE_REQUEST_BYTES);
|
||||
if (raw === null) return c.json({ error: "Request body is too large." }, 413);
|
||||
const body = parseVoiceRequestBody(raw);
|
||||
const audioBase64 = String((body as { audioBase64?: unknown }).audioBase64 ?? "");
|
||||
try {
|
||||
audio = decodeAudioBase64(audioBase64);
|
||||
mimeType = String((body as { mimeType?: unknown }).mimeType ?? "audio/webm");
|
||||
} catch (error) {
|
||||
return voiceHttpError(c, error);
|
||||
}
|
||||
} else {
|
||||
const rawBytes = await readBoundedBytes(c.req.raw, MAX_TRANSCRIBE_REQUEST_BYTES);
|
||||
if (rawBytes === null) return c.json({ error: "Request body is too large." }, 413);
|
||||
if (rawBytes.byteLength === 0) {
|
||||
return voiceHttpError(c, new ORPCError("BAD_REQUEST", { message: "Recording is empty." }));
|
||||
}
|
||||
audio = rawBytes;
|
||||
mimeType = contentType.split(";")[0]?.trim() || "audio/webm";
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await transcribeVoice(deps, actor, {
|
||||
audio,
|
||||
mimeType,
|
||||
signal: c.req.raw.signal,
|
||||
});
|
||||
|
||||
const accept = c.req.header("accept") ?? "";
|
||||
if (accept.includes("application/json") && !accept.includes("text/event-stream")) {
|
||||
return c.json({ text: result.text, isFinal: true });
|
||||
}
|
||||
|
||||
const encoder = new TextEncoder();
|
||||
const stream = new ReadableStream({
|
||||
start(controller) {
|
||||
controller.enqueue(
|
||||
encoder.encode(
|
||||
`data: ${JSON.stringify({ text: result.text, delta: result.text, isFinal: true })}\n\n`,
|
||||
),
|
||||
);
|
||||
controller.enqueue(encoder.encode(`data: [DONE]\n\n`));
|
||||
controller.close();
|
||||
},
|
||||
});
|
||||
|
||||
return new Response(stream, {
|
||||
headers: {
|
||||
"content-type": "text/event-stream; charset=utf-8",
|
||||
"cache-control": "no-cache, no-transform",
|
||||
connection: "keep-alive",
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
return voiceHttpError(c, error);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function readBoundedBytes(request: Request, maxBytes: number): Promise<Uint8Array | null> {
|
||||
const contentLengthHeader = request.headers.get("content-length");
|
||||
if (contentLengthHeader !== null) {
|
||||
const contentLength = Number(contentLengthHeader);
|
||||
if (!Number.isFinite(contentLength) || contentLength < 0 || contentLength > maxBytes) {
|
||||
cancelBody(request.body);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
if (!request.body) return new Uint8Array(0);
|
||||
|
||||
const reader = request.body.getReader();
|
||||
const chunks: Uint8Array[] = [];
|
||||
let bytes = 0;
|
||||
try {
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
bytes += value.byteLength;
|
||||
if (bytes > maxBytes) {
|
||||
cancelBody(reader);
|
||||
return null;
|
||||
}
|
||||
chunks.push(value);
|
||||
}
|
||||
const combined = new Uint8Array(bytes);
|
||||
let offset = 0;
|
||||
for (const chunk of chunks) {
|
||||
combined.set(chunk, offset);
|
||||
offset += chunk.byteLength;
|
||||
}
|
||||
return combined;
|
||||
} finally {
|
||||
reader.releaseLock();
|
||||
}
|
||||
}
|
||||
|
||||
function parseVoiceRequestBody(raw: string): Record<string, unknown> {
|
||||
|
||||
36
apps/api/src/web-origin.test.ts
Normal file
36
apps/api/src/web-origin.test.ts
Normal file
@ -0,0 +1,36 @@
|
||||
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",
|
||||
);
|
||||
});
|
||||
|
||||
it("accepts origins listed in extraTrustedOrigins", () => {
|
||||
expect(
|
||||
requestWebOrigin("https://host.example.ts.net:8443", configured, [
|
||||
"https://host.example.ts.net:8443",
|
||||
]),
|
||||
).toBe("https://host.example.ts.net:8443");
|
||||
});
|
||||
});
|
||||
35
apps/api/src/web-origin.ts
Normal file
35
apps/api/src/web-origin.ts
Normal file
@ -0,0 +1,35 @@
|
||||
/** 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, private-network,
|
||||
* or explicitly trusted origin gets its own origin back so a phone on the LAN
|
||||
* or Tailscale is not sent to 127.0.0.1; any other caller gets WEB_ORIGIN. */
|
||||
export function requestWebOrigin(
|
||||
origin: string | undefined,
|
||||
webOrigin: string,
|
||||
extraTrustedOrigins: readonly 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;
|
||||
if (extraTrustedOrigins.includes(url.origin)) return url.origin;
|
||||
return isPrivateOrLocalHost(url.hostname) ? url.origin : webOrigin;
|
||||
} catch {
|
||||
return webOrigin;
|
||||
}
|
||||
}
|
||||
@ -177,7 +177,7 @@ test("This computer installs and starts the stack, then opens the app", async ()
|
||||
|
||||
const appWindowPromise = app.waitForEvent("window");
|
||||
await setup.getByRole("button", { name: "Continue" }).click();
|
||||
await expect(setup.locator("#stack-phase")).toHaveText("Downloading Rakazo…");
|
||||
await expect(setup.locator("#stack-phase")).toHaveText("Downloading evOo…");
|
||||
await expect(setup.getByRole("button", { name: "Continue" })).toBeDisabled();
|
||||
// Docker output stays behind the details toggle; the phase, the bar, and the size show by default.
|
||||
await expect(setup.locator("#stack-detail")).toHaveText("412 MB downloaded");
|
||||
@ -249,7 +249,7 @@ test("switching to Existing instance while the stack starts keeps that choice",
|
||||
const setup = await app.firstWindow();
|
||||
|
||||
await setup.getByRole("button", { name: "Continue" }).click();
|
||||
await expect(setup.locator("#stack-phase")).toHaveText("Downloading Rakazo…");
|
||||
await expect(setup.locator("#stack-phase")).toHaveText("Downloading evOo…");
|
||||
|
||||
// Fake docker sleeps during pull; leave This computer before ready so followStack must not save.
|
||||
await setup.getByRole("radio", { name: /Existing instance/ }).check();
|
||||
|
||||
@ -83,7 +83,7 @@ test("first run asks whether to use a local or existing instance", async () => {
|
||||
}
|
||||
const setup = await app.firstWindow();
|
||||
|
||||
await expect(setup.getByRole("heading", { name: "Welcome to Rakazo" })).toBeVisible();
|
||||
await expect(setup.getByRole("heading", { name: "Welcome to evOo" })).toBeVisible();
|
||||
await expect(setup.getByText("Choose which server this app should use.")).toBeVisible();
|
||||
await expect(setup.getByText("This computer")).toBeVisible();
|
||||
await expect(setup.getByText("Existing instance")).toBeVisible();
|
||||
@ -139,7 +139,7 @@ test("connecting to an existing instance verifies, saves, and opens it", async (
|
||||
|
||||
await setup.locator("#server-url").fill(serverUrl);
|
||||
await setup.getByRole("button", { name: "Check connection" }).click();
|
||||
await expect(setup.locator("#status")).toHaveText(`Rakazo answered at ${serverUrl}.`);
|
||||
await expect(setup.locator("#status")).toHaveText(`evOo answered at ${serverUrl}.`);
|
||||
await expect(setup.locator("#status")).toHaveAttribute("data-tone", "ok");
|
||||
|
||||
await setup.screenshot({
|
||||
@ -437,7 +437,7 @@ test("a malformed address is rejected before anything is written", async () => {
|
||||
}).toPass();
|
||||
});
|
||||
|
||||
test("a generic web page is not accepted as a Rakazo server", async () => {
|
||||
test("a generic web page is not accepted as an evOo server", async () => {
|
||||
const plain = createServer((_request, response) => {
|
||||
response.writeHead(200, { "content-type": "text/html; charset=utf-8" });
|
||||
response.end("<!doctype html><p>not Rakazo</p>");
|
||||
@ -454,7 +454,7 @@ test("a generic web page is not accepted as a Rakazo server", async () => {
|
||||
await setup.getByRole("button", { name: "Continue" }).click();
|
||||
|
||||
await expect(setup.locator("#status")).toHaveText(
|
||||
"That address did not respond like a Rakazo server.",
|
||||
"That address did not respond like an evOo server.",
|
||||
);
|
||||
await expect(async () => {
|
||||
await expect(readFile(path.join(userData, "setup.json"), "utf8")).rejects.toThrow();
|
||||
@ -507,7 +507,7 @@ test("an unreachable saved server falls back to setup with a recovery message",
|
||||
app = await launch();
|
||||
const setup = await app.firstWindow();
|
||||
|
||||
await expect(setup.getByRole("heading", { name: "Welcome to Rakazo" })).toBeVisible();
|
||||
await expect(setup.getByRole("heading", { name: "Welcome to evOo" })).toBeVisible();
|
||||
await expect(setup.getByRole("radio", { name: /Existing instance/ })).toBeChecked();
|
||||
await expect(setup.locator("#server-url")).toHaveValue(closedUrl);
|
||||
await expect(setup.locator("#status")).toContainText("Could not reconnect to the saved server.");
|
||||
@ -529,7 +529,7 @@ test("the native application menu can reopen setup without exposing setup IPC to
|
||||
});
|
||||
const setup = await setupPromise;
|
||||
|
||||
await expect(setup.getByRole("heading", { name: "Welcome to Rakazo" })).toBeVisible();
|
||||
await expect(setup.getByRole("heading", { name: "Welcome to evOo" })).toBeVisible();
|
||||
await expect(setup.locator("#status")).toBeEmpty();
|
||||
|
||||
// Closing setup without saving restores the connected instance.
|
||||
|
||||
@ -80,8 +80,8 @@
|
||||
"publish": [
|
||||
{
|
||||
"provider": "github",
|
||||
"owner": "elie222",
|
||||
"repo": "rakazo"
|
||||
"owner": "cynarAI",
|
||||
"repo": "evOo-local"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@ -79,7 +79,7 @@ describe("renderStackEnv", () => {
|
||||
expect(lines.some((line) => line.startsWith("RAKAZO_IMAGE_TAG="))).toBe(false);
|
||||
expect(lines.some((line) => line.startsWith("RAKAZO_COMPUTER_IMAGE_TAG="))).toBe(false);
|
||||
// Everything else, including the image names and empty optional keys, stays verbatim.
|
||||
expect(lines).toContain("RAKAZO_IMAGE=ghcr.io/elie222/rakazo/app");
|
||||
expect(lines).toContain("RAKAZO_IMAGE=ghcr.io/cynarai/evoo-local/app");
|
||||
expect(lines).toContain("SANDBOX_PROVIDER=docker");
|
||||
expect(lines).toContain("OPENROUTER_API_KEY=");
|
||||
expect(rendered.endsWith("\n")).toBe(template.endsWith("\n"));
|
||||
|
||||
@ -711,7 +711,7 @@ function installApplicationMenu() {
|
||||
};
|
||||
const changeServer: Electron.MenuItemConstructorOptions = {
|
||||
id: "change-rakazo-server",
|
||||
label: "Change Rakazo Server…",
|
||||
label: "Change evOo Server…",
|
||||
accelerator: "CmdOrCtrl+Shift+K",
|
||||
click: () => showSetupWindow(),
|
||||
};
|
||||
@ -789,7 +789,7 @@ async function probeServer(rawUrl: string, signal?: AbortSignal): Promise<Deskto
|
||||
ok: false,
|
||||
status: response.status,
|
||||
url,
|
||||
error: "That address redirects elsewhere. Enter the final Rakazo server address.",
|
||||
error: "That address redirects elsewhere. Enter the final evOo server address.",
|
||||
};
|
||||
}
|
||||
if (!response.ok) {
|
||||
@ -806,7 +806,7 @@ async function probeServer(rawUrl: string, signal?: AbortSignal): Promise<Deskto
|
||||
ok: false,
|
||||
status: response.status,
|
||||
url,
|
||||
error: "That address did not respond like a Rakazo server.",
|
||||
error: "That address did not respond like an evOo server.",
|
||||
};
|
||||
}
|
||||
return {
|
||||
@ -1249,7 +1249,7 @@ app.whenReady().then(async () => {
|
||||
if (managedUrl === null || !(await localStack.matchesDesiredStack())) {
|
||||
return {
|
||||
ok: false,
|
||||
error: "The app-managed Rakazo services are not ready. Retry setup.",
|
||||
error: "The app-managed evOo services are not ready. Retry setup.",
|
||||
};
|
||||
}
|
||||
openSetup = { mode: "new", serverUrl: managedUrl };
|
||||
|
||||
@ -66,9 +66,9 @@ describe("desktop release workflow", () => {
|
||||
`include='[{"runner":["self-hosted","evoo-ci","macOS"],"artifact":"macos"},{"runner":["self-hosted","evoo-ci","Linux"],"artifact":"linux"}]'`,
|
||||
);
|
||||
expect(workflow).toMatch(
|
||||
/if \[\[ "\$WINDOWS_SIGNING" == "true" \]\]; then\n(?:\s*#[^\n]*\n)*\s+include=.*\{"runner":"windows-2022","artifact":"windows"\}/,
|
||||
/if \[\[ "\$WINDOWS_SIGNING" == "true" \]\]; then\n(?:\s*#[^\n]*\n)*\s+include=.*\{"runner":\["self-hosted","evoo-ci","Windows"\],"artifact":"windows"\}/,
|
||||
);
|
||||
expect(workflow.split("windows-2022")).toHaveLength(2);
|
||||
expect(workflow).not.toMatch(/windows-\d|windows-latest/);
|
||||
expect(workflow).toContain(`WINDOWS_BUILT: ${expression("needs.validate.outputs.windows")}`);
|
||||
expect(workflow).toContain('if [[ "$WINDOWS_BUILT" == "true" ]]; then');
|
||||
expect(workflow).toContain("feeds+=(release-artifacts/latest.yml)");
|
||||
@ -76,8 +76,8 @@ describe("desktop release workflow", () => {
|
||||
|
||||
it("pins every platform update feed to the official GitHub owner and repo", () => {
|
||||
expect(workflow).toContain('grep -Fqx "provider: github"');
|
||||
expect(workflow).toContain('grep -Fqx "owner: elie222"');
|
||||
expect(workflow).toContain('grep -Fqx "repo: rakazo"');
|
||||
expect(workflow).toContain('grep -Fqx "owner: cynarAI"');
|
||||
expect(workflow).toContain('grep -Fqx "repo: evOo-local"');
|
||||
expect(workflow).toContain("Verify Linux update feed is pinned to the official GitHub channel");
|
||||
expect(workflow).toContain("Windows update config missing");
|
||||
expect(workflow).toContain("RELEASE_VERSION:");
|
||||
@ -94,4 +94,11 @@ describe("desktop release workflow", () => {
|
||||
expect(workflow).toContain("--draft --generate-notes");
|
||||
expect(workflow).toContain("--draft=false --latest");
|
||||
});
|
||||
|
||||
it("hands platform builds to the draft release, never to Actions artifact storage", () => {
|
||||
expect(workflow).not.toMatch(/actions\/(?:upload|download)-artifact@/);
|
||||
expect(workflow).toContain("bash scripts/upload-release-assets.sh");
|
||||
expect(workflow).toContain('gh release download "$RELEASE_TAG" --dir release-artifacts');
|
||||
expect(workflow).toContain("needs: [validate, build, publish]");
|
||||
});
|
||||
});
|
||||
|
||||
@ -6,18 +6,18 @@
|
||||
http-equiv="Content-Security-Policy"
|
||||
content="default-src 'none'; script-src 'self'; style-src 'self'; img-src 'self' data:; form-action 'none'; base-uri 'none'"
|
||||
/>
|
||||
<title>Set up Rakazo</title>
|
||||
<title>Set up evOo</title>
|
||||
<link rel="stylesheet" href="tokens.css" />
|
||||
<link rel="stylesheet" href="setup.css" />
|
||||
</head>
|
||||
<body>
|
||||
<header class="titlebar">
|
||||
<span class="titlebar-name">Rakazo</span>
|
||||
<button id="quit" class="titlebar-quit" type="button" title="Quit Rakazo">Quit</button>
|
||||
<span class="titlebar-name">evOo</span>
|
||||
<button id="quit" class="titlebar-quit" type="button" title="Quit evOo">Quit</button>
|
||||
</header>
|
||||
|
||||
<main class="shell">
|
||||
<h1 class="heading">Welcome to Rakazo</h1>
|
||||
<h1 class="heading">Welcome to evOo</h1>
|
||||
<p class="subheading">Choose which server this app should use.</p>
|
||||
|
||||
<form id="setup" novalidate>
|
||||
|
||||
@ -23,10 +23,10 @@
|
||||
const PHASE_LABELS = {
|
||||
"checking-docker": "Getting ready…",
|
||||
preparing: "Getting ready…",
|
||||
pulling: "Downloading Rakazo…",
|
||||
starting: "Starting Rakazo…",
|
||||
pulling: "Downloading evOo…",
|
||||
starting: "Starting evOo…",
|
||||
"waiting-healthy": "Almost ready…",
|
||||
ready: "Rakazo is ready.",
|
||||
ready: "evOo is ready.",
|
||||
};
|
||||
const TERMINAL_PHASES = new Set([
|
||||
"idle",
|
||||
@ -266,7 +266,7 @@
|
||||
const result = await bridge.test(value);
|
||||
if (result.ok) {
|
||||
serverUrl.value = result.url;
|
||||
setStatus(`Rakazo answered at ${result.url}.`, "ok");
|
||||
setStatus(`evOo answered at ${result.url}.`, "ok");
|
||||
} else {
|
||||
setStatus(result.error ?? "Could not reach that address.", "error");
|
||||
}
|
||||
@ -362,7 +362,7 @@
|
||||
continueButton.focus();
|
||||
}
|
||||
} catch {
|
||||
setStatus("Setup could not start. Quit Rakazo and try again.", "error");
|
||||
setStatus("Setup could not start. Quit evOo and try again.", "error");
|
||||
setBusy(true);
|
||||
}
|
||||
}
|
||||
|
||||
@ -7,7 +7,7 @@ name: Android live working status demonstration
|
||||
notifications: allow
|
||||
# A cold API 34 emulator can show a launcher ANR over the healthy app.
|
||||
- extendedWaitUntil:
|
||||
visible: "Sign up for Rakazo|Pixel Launcher isn't responding"
|
||||
visible: "Sign up for evOo|Pixel Launcher isn't responding"
|
||||
timeout: 30000
|
||||
- runFlow:
|
||||
when:
|
||||
@ -15,7 +15,7 @@ name: Android live working status demonstration
|
||||
commands:
|
||||
- tapOn: "Close app"
|
||||
- extendedWaitUntil:
|
||||
visible: "Sign up for Rakazo"
|
||||
visible: "Sign up for evOo"
|
||||
timeout: 30000
|
||||
- tapOn: "Sign in"
|
||||
- tapOn: "Email"
|
||||
|
||||
@ -10,7 +10,7 @@ tags:
|
||||
|
||||
# A cold API 34 emulator can show a launcher ANR over the healthy app.
|
||||
- extendedWaitUntil:
|
||||
visible: "Sign up for Rakazo|Pixel Launcher isn't responding"
|
||||
visible: "Sign up for evOo|Pixel Launcher isn't responding"
|
||||
timeout: 30000
|
||||
- runFlow:
|
||||
when:
|
||||
@ -18,11 +18,11 @@ tags:
|
||||
commands:
|
||||
- tapOn: "Close app"
|
||||
- extendedWaitUntil:
|
||||
visible: "Sign up for Rakazo"
|
||||
visible: "Sign up for evOo"
|
||||
timeout: 30000
|
||||
- takeScreenshot: "02-sign-up"
|
||||
- tapOn: "Sign in"
|
||||
- assertVisible: "Sign in to Rakazo"
|
||||
- assertVisible: "Sign in to evOo"
|
||||
- takeScreenshot: "01-sign-in"
|
||||
|
||||
- tapOn: "Use a custom server"
|
||||
|
||||
@ -4,18 +4,18 @@ name: Rakazo mobile smoke
|
||||
- launchApp:
|
||||
clearState: true
|
||||
|
||||
- assertVisible: "Sign in to Rakazo"
|
||||
- assertVisible: "Sign in to evOo"
|
||||
- assertVisible: "Sign up"
|
||||
- tapOn: "Sign up"
|
||||
- assertVisible: "Sign up for Rakazo"
|
||||
- assertVisible: "Sign up for evOo"
|
||||
- assertVisible: "Name"
|
||||
- tapOn: "Sign in"
|
||||
- assertVisible: "Sign in to Rakazo"
|
||||
- assertVisible: "Sign in to evOo"
|
||||
- tapOn: "Email"
|
||||
- inputText: ${RAKAZO_E2E_EMAIL}
|
||||
- tapOn: "Password"
|
||||
- inputText: ${RAKAZO_E2E_PASSWORD}
|
||||
- tapOn: "Sign in to Rakazo"
|
||||
- tapOn: "Sign in to evOo"
|
||||
- tapOn: "Sign in"
|
||||
|
||||
- extendedWaitUntil:
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
{
|
||||
"expo": {
|
||||
"name": "Rakazo",
|
||||
"name": "evOo",
|
||||
"slug": "rakazo",
|
||||
"owner": "inbox-zero",
|
||||
"scheme": "rakazo",
|
||||
@ -33,8 +33,8 @@
|
||||
[
|
||||
"expo-image-picker",
|
||||
{
|
||||
"photosPermission": "Rakazo uses your photo library so you can attach images to a bot message.",
|
||||
"cameraPermission": "Rakazo uses the camera so you can attach photos to a bot message.",
|
||||
"photosPermission": "evOo uses your photo library so you can attach images to a bot message.",
|
||||
"cameraPermission": "evOo uses the camera so you can attach photos to a bot message.",
|
||||
"microphonePermission": false
|
||||
}
|
||||
],
|
||||
@ -51,9 +51,9 @@
|
||||
"usesNonExemptEncryption": false
|
||||
},
|
||||
"infoPlist": {
|
||||
"NSCameraUsageDescription": "Rakazo uses the camera so you can attach photos to a bot message.",
|
||||
"NSPhotoLibraryUsageDescription": "Rakazo uses your photo library so you can attach images to a bot message.",
|
||||
"NSLocalNetworkUsageDescription": "Rakazo connects to a self-hosted Rakazo server on your local network.",
|
||||
"NSCameraUsageDescription": "evOo uses the camera so you can attach photos to a bot message.",
|
||||
"NSPhotoLibraryUsageDescription": "evOo uses your photo library so you can attach images to a bot message.",
|
||||
"NSLocalNetworkUsageDescription": "evOo connects to a self-hosted evOo server on your local network.",
|
||||
"NSAppTransportSecurity": {
|
||||
"NSAllowsLocalNetworking": true
|
||||
}
|
||||
|
||||
@ -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: "evOo" }} />
|
||||
<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,
|
||||
@ -49,6 +46,7 @@ import { native, useResolvedAppearance, useThemedStyles } from "../lib/native";
|
||||
import { registerPushToken } from "../lib/push";
|
||||
import type { AccountUiLocale } from "../lib/ui-locale";
|
||||
import { ACCOUNT_UI_LOCALES, UI_LOCALE_LABELS } from "../lib/ui-locale";
|
||||
import { alignVoiceWithLocale } from "../lib/voice";
|
||||
|
||||
/** Render account settings, including the entry point for voice configuration. */
|
||||
export default function Account() {
|
||||
@ -61,8 +59,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 +72,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 +122,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);
|
||||
@ -202,6 +184,7 @@ export default function Account() {
|
||||
setLocaleSaving(true);
|
||||
setLocaleError(null);
|
||||
void setUiLocale(code)
|
||||
.then((activated) => alignVoiceWithLocale(activated).catch(() => undefined))
|
||||
.catch(() => {
|
||||
setLocaleError(t("Could not change language"));
|
||||
})
|
||||
@ -260,7 +243,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 +275,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")}
|
||||
@ -404,15 +353,17 @@ export default function Account() {
|
||||
</View>
|
||||
) : null}
|
||||
|
||||
<Pressable
|
||||
accessibilityRole="button"
|
||||
disabled={pending}
|
||||
onPress={() => router.push("/models")}
|
||||
style={({ pressed }) => [styles.settingsButton, pressed && styles.pressed]}
|
||||
>
|
||||
<Text style={styles.settingsTitle}>{t("Models")}</Text>
|
||||
<Text style={styles.chevron}>›</Text>
|
||||
</Pressable>
|
||||
{me?.modelsManaged ? null : (
|
||||
<Pressable
|
||||
accessibilityRole="button"
|
||||
disabled={pending}
|
||||
onPress={() => router.push("/models")}
|
||||
style={({ pressed }) => [styles.settingsButton, pressed && styles.pressed]}
|
||||
>
|
||||
<Text style={styles.settingsTitle}>{t("Models")}</Text>
|
||||
<Text style={styles.chevron}>›</Text>
|
||||
</Pressable>
|
||||
)}
|
||||
|
||||
<Pressable
|
||||
accessibilityRole="button"
|
||||
@ -649,7 +600,7 @@ function createAccountStyles() {
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
},
|
||||
avatarSection: {
|
||||
appearanceSection: {
|
||||
borderRadius: 16,
|
||||
backgroundColor: native.fill,
|
||||
padding: 18,
|
||||
@ -677,29 +628,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,53 +349,31 @@ 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"
|
||||
accessibilityLabel={t("Advanced")}
|
||||
accessibilityState={{ expanded: advancedOpen }}
|
||||
onPress={() => setAdvancedOpen((open) => !open)}
|
||||
style={{
|
||||
marginTop: 20,
|
||||
minHeight: 44,
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
}}
|
||||
>
|
||||
<Text style={{ color: tokens.mutedForeground, fontSize: 14 }}>{t("Advanced")}</Text>
|
||||
<Text style={{ color: tokens.mutedForeground, fontSize: 18 }}>
|
||||
{advancedOpen ? "⌃" : "⌄"}
|
||||
</Text>
|
||||
</Pressable>
|
||||
{advancedOpen ? (
|
||||
{me?.modelsManaged ? null : (
|
||||
<Pressable
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={t("Advanced")}
|
||||
accessibilityState={{ expanded: advancedOpen }}
|
||||
onPress={() => setAdvancedOpen((open) => !open)}
|
||||
style={{
|
||||
marginTop: 20,
|
||||
minHeight: 44,
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
}}
|
||||
>
|
||||
<Text style={{ color: tokens.mutedForeground, fontSize: 14 }}>{t("Advanced")}</Text>
|
||||
<Text style={{ color: tokens.mutedForeground, fontSize: 18 }}>
|
||||
{advancedOpen ? "⌃" : "⌄"}
|
||||
</Text>
|
||||
</Pressable>
|
||||
)}
|
||||
{advancedOpen && !me?.modelsManaged ? (
|
||||
<View>
|
||||
<Text
|
||||
style={{
|
||||
|
||||
@ -3,8 +3,15 @@ import { Stack, useLocalSearchParams, useRouter } from "expo-router";
|
||||
import { useEffect, useState } from "react";
|
||||
import { Alert, Pressable, ScrollView, Text, TextInput } from "react-native";
|
||||
import { BotMemberPicker } from "../components/bot-member-picker";
|
||||
import { type MobileBot, type MobileGroup, rpc } from "../lib/api";
|
||||
import {
|
||||
type MobileBot,
|
||||
type MobileGroup,
|
||||
type MobileSpace,
|
||||
type MobileSpaceNavigation,
|
||||
rpc,
|
||||
} from "../lib/api";
|
||||
import { useI18n } from "../lib/i18n";
|
||||
import { canDeleteInboxSpace, removeInboxSpace } from "../lib/inbox-spaces";
|
||||
import { useMobileTokens } from "../lib/native";
|
||||
|
||||
export default function GroupSettingsScreen() {
|
||||
@ -13,12 +20,16 @@ export default function GroupSettingsScreen() {
|
||||
const router = useRouter();
|
||||
const { groupId } = useLocalSearchParams<{ groupId: string }>();
|
||||
const [group, setGroup] = useState<MobileGroup | null>(null);
|
||||
const [projectSpace, setProjectSpace] = useState<MobileSpace | null>(null);
|
||||
const [bots, setBots] = useState<MobileBot[]>([]);
|
||||
const [name, setName] = useState("");
|
||||
const [selected, setSelected] = useState<string[]>([]);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [pending, setPending] = useState(false);
|
||||
|
||||
const isMainConversation = group?.kind === "main";
|
||||
const isProjectMain = isMainConversation && projectSpace !== null && !projectSpace.isDefault;
|
||||
|
||||
useEffect(() => {
|
||||
if (!groupId) return;
|
||||
void Promise.all([
|
||||
@ -26,11 +37,19 @@ export default function GroupSettingsScreen() {
|
||||
(groups) => groups.find((row) => row.id === groupId) ?? null,
|
||||
),
|
||||
rpc<MobileBot[]>("bots/list"),
|
||||
rpc<MobileSpaceNavigation>("spaces/list").catch(() => null),
|
||||
])
|
||||
.then(([nextGroup, nextBots]) => {
|
||||
.then(([nextGroup, nextBots, navigation]) => {
|
||||
if (!nextGroup) throw new Error(t("Group not found"));
|
||||
setGroup(nextGroup);
|
||||
setName(nextGroup.name);
|
||||
const space =
|
||||
navigation?.spaces.find(
|
||||
(item) => item.id === nextGroup.spaceId || item.id === navigation.current.id,
|
||||
) ?? null;
|
||||
setProjectSpace(space);
|
||||
setName(
|
||||
nextGroup.kind === "main" && space && !space.isDefault ? space.name : nextGroup.name,
|
||||
);
|
||||
setSelected(nextGroup.members.map((member) => member.botId));
|
||||
setBots(nextBots.filter((bot) => !bot.archivedAt));
|
||||
})
|
||||
@ -42,10 +61,20 @@ export default function GroupSettingsScreen() {
|
||||
setPending(true);
|
||||
setError(null);
|
||||
try {
|
||||
const trimmed = name.trim();
|
||||
if (isProjectMain && projectSpace) {
|
||||
if (trimmed && trimmed !== projectSpace.name) {
|
||||
await rpc("spaces/update", { spaceId: projectSpace.id, name: trimmed });
|
||||
}
|
||||
router.back();
|
||||
return;
|
||||
}
|
||||
const input: { groupId: string; name?: string; botIds?: string[] } = { groupId };
|
||||
if (name.trim() !== group.name) input.name = name.trim();
|
||||
const memberIds = group.members.map((member) => member.botId).join(",");
|
||||
if (selected.join(",") !== memberIds) input.botIds = selected;
|
||||
if (trimmed !== group.name) input.name = trimmed;
|
||||
if (!isMainConversation) {
|
||||
const memberIds = group.members.map((member) => member.botId).join(",");
|
||||
if (selected.join(",") !== memberIds) input.botIds = selected;
|
||||
}
|
||||
if (input.name || input.botIds) await rpc("groups/update", input);
|
||||
router.back();
|
||||
} catch (err) {
|
||||
@ -57,6 +86,29 @@ export default function GroupSettingsScreen() {
|
||||
|
||||
function remove() {
|
||||
if (!groupId || !group) return;
|
||||
if (isProjectMain && projectSpace) {
|
||||
Alert.alert(
|
||||
t("Delete {name}?", { name: projectSpace.name }),
|
||||
t("This removes the empty space for everyone."),
|
||||
[
|
||||
{ text: t("Cancel"), style: "cancel" },
|
||||
{
|
||||
text: t("Delete"),
|
||||
style: "destructive",
|
||||
onPress: () =>
|
||||
void removeInboxSpace(projectSpace.id, async () => {})
|
||||
.then(() => router.replace("/"))
|
||||
.catch((err) =>
|
||||
Alert.alert(
|
||||
t("Could not delete space"),
|
||||
err instanceof Error ? err.message : t("Try again."),
|
||||
),
|
||||
),
|
||||
},
|
||||
],
|
||||
);
|
||||
return;
|
||||
}
|
||||
Alert.alert(group.name, t("Delete this group? Bots and their solo threads are kept."), [
|
||||
{ text: t("Cancel"), style: "cancel" },
|
||||
{
|
||||
@ -75,6 +127,12 @@ export default function GroupSettingsScreen() {
|
||||
]);
|
||||
}
|
||||
|
||||
const saveDisabled =
|
||||
!name.trim() ||
|
||||
pending ||
|
||||
(!isMainConversation &&
|
||||
(selected.length < GROUP_MEMBER_MIN || selected.length > GROUP_MEMBER_MAX));
|
||||
|
||||
return (
|
||||
<>
|
||||
<Stack.Screen options={{ title: t("Group settings") }} />
|
||||
@ -97,34 +155,27 @@ export default function GroupSettingsScreen() {
|
||||
fontSize: 16,
|
||||
}}
|
||||
/>
|
||||
<Text style={{ color: tokens.mutedForeground, fontSize: 14, marginTop: 20 }}>
|
||||
{t("Members")}
|
||||
</Text>
|
||||
<BotMemberPicker
|
||||
bots={bots}
|
||||
selected={selected}
|
||||
onChange={setSelected}
|
||||
disabled={pending}
|
||||
/>
|
||||
{!isMainConversation ? (
|
||||
<>
|
||||
<Text style={{ color: tokens.mutedForeground, fontSize: 14, marginTop: 20 }}>
|
||||
{t("Members")}
|
||||
</Text>
|
||||
<BotMemberPicker
|
||||
bots={bots}
|
||||
selected={selected}
|
||||
onChange={setSelected}
|
||||
disabled={pending}
|
||||
/>
|
||||
</>
|
||||
) : null}
|
||||
{error ? <Text style={{ color: tokens.destructive, marginTop: 12 }}>{error}</Text> : null}
|
||||
<Pressable
|
||||
onPress={() => void save()}
|
||||
disabled={
|
||||
!name.trim() ||
|
||||
selected.length < GROUP_MEMBER_MIN ||
|
||||
selected.length > GROUP_MEMBER_MAX ||
|
||||
pending
|
||||
}
|
||||
disabled={saveDisabled}
|
||||
style={{
|
||||
marginTop: 24,
|
||||
backgroundColor: tokens.primary,
|
||||
opacity:
|
||||
!name.trim() ||
|
||||
selected.length < GROUP_MEMBER_MIN ||
|
||||
selected.length > GROUP_MEMBER_MAX ||
|
||||
pending
|
||||
? 0.5
|
||||
: 1,
|
||||
opacity: saveDisabled ? 0.5 : 1,
|
||||
borderRadius: 11,
|
||||
padding: 14,
|
||||
alignItems: "center",
|
||||
@ -134,19 +185,35 @@ export default function GroupSettingsScreen() {
|
||||
{pending ? t("Saving…") : t("Save")}
|
||||
</Text>
|
||||
</Pressable>
|
||||
<Pressable
|
||||
onPress={remove}
|
||||
style={{
|
||||
marginTop: 16,
|
||||
borderRadius: 11,
|
||||
borderWidth: 1,
|
||||
borderColor: tokens.border,
|
||||
padding: 14,
|
||||
alignItems: "center",
|
||||
}}
|
||||
>
|
||||
<Text style={{ color: tokens.destructive, fontSize: 16 }}>{t("Delete group")}</Text>
|
||||
</Pressable>
|
||||
{isProjectMain && projectSpace && canDeleteInboxSpace(projectSpace) ? (
|
||||
<Pressable
|
||||
onPress={remove}
|
||||
style={{
|
||||
marginTop: 16,
|
||||
borderRadius: 11,
|
||||
borderWidth: 1,
|
||||
borderColor: tokens.border,
|
||||
padding: 14,
|
||||
alignItems: "center",
|
||||
}}
|
||||
>
|
||||
<Text style={{ color: tokens.destructive, fontSize: 16 }}>{t("Delete space")}</Text>
|
||||
</Pressable>
|
||||
) : !isMainConversation ? (
|
||||
<Pressable
|
||||
onPress={remove}
|
||||
style={{
|
||||
marginTop: 16,
|
||||
borderRadius: 11,
|
||||
borderWidth: 1,
|
||||
borderColor: tokens.border,
|
||||
padding: 14,
|
||||
alignItems: "center",
|
||||
}}
|
||||
>
|
||||
<Text style={{ color: tokens.destructive, fontSize: 16 }}>{t("Delete group")}</Text>
|
||||
</Pressable>
|
||||
) : null}
|
||||
</ScrollView>
|
||||
</>
|
||||
);
|
||||
|
||||
@ -1,3 +1,4 @@
|
||||
import type { AutopilotRecent } from "@rakazo/contracts";
|
||||
import {
|
||||
normalizeCreateBotProfile,
|
||||
type RunActivityRow,
|
||||
@ -5,6 +6,7 @@ import {
|
||||
type SpaceBot,
|
||||
type SpaceGroup,
|
||||
} from "@rakazo/contracts";
|
||||
import { projectLook, runChatTitle } from "@rakazo/core";
|
||||
import { botColors } from "@rakazo/ui-tokens";
|
||||
import { Redirect, useFocusEffect, useRouter } from "expo-router";
|
||||
import { type ReactNode, useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
@ -43,9 +45,11 @@ import {
|
||||
rpc,
|
||||
selectedSpaceId,
|
||||
selectInitialSpace,
|
||||
selectLibrarySpace,
|
||||
selectSpace,
|
||||
} from "../lib/api";
|
||||
import { mobileTokens, resolveMobileAppearance } from "../lib/appearance";
|
||||
import { mobileShellIconSizes, 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 +91,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 +136,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 +151,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"));
|
||||
@ -405,6 +412,7 @@ export default function Home() {
|
||||
return;
|
||||
creatingBotRef.current = true;
|
||||
try {
|
||||
await selectLibrarySpace();
|
||||
// Authoritative roster so a slow home fetch does not treat later bots as first.
|
||||
// A failed list is unknown — use the delayed path rather than assuming first.
|
||||
const existing = await rpc<MobileBot[]>("bots/list").catch(() => null);
|
||||
@ -456,7 +464,7 @@ export default function Home() {
|
||||
<NativeSymbol
|
||||
ios={activityMode ? "bell.fill" : "bell"}
|
||||
android={activityMode ? "notifications" : "notifications-outline"}
|
||||
size={17}
|
||||
size={mobileShellIconSizes.md}
|
||||
color={activityMode ? tokens.primaryForeground : tokens.foreground}
|
||||
/>
|
||||
</CircleButton>
|
||||
@ -470,7 +478,7 @@ export default function Home() {
|
||||
})
|
||||
}
|
||||
>
|
||||
<NativeSymbol ios="magnifyingglass" android="search" size={17} />
|
||||
<NativeSymbol ios="magnifyingglass" android="search" size={mobileShellIconSizes.md} />
|
||||
</CircleButton>
|
||||
<CircleButton
|
||||
accessibilityLabel={t("Create")}
|
||||
@ -484,7 +492,7 @@ export default function Home() {
|
||||
]);
|
||||
}}
|
||||
>
|
||||
<NativeSymbol ios="plus" android="add" size={18} />
|
||||
<NativeSymbol ios="plus" android="add" size={mobileShellIconSizes.lg} />
|
||||
</CircleButton>
|
||||
</View>
|
||||
</View>
|
||||
@ -543,11 +551,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={
|
||||
@ -585,10 +597,25 @@ export default function Home() {
|
||||
}}
|
||||
disabled={spaceBusy}
|
||||
onPress={() => {
|
||||
if (item.space) void chooseInboxSpace(item.space.id);
|
||||
if (!item.space) return;
|
||||
const targetSpace = item.space;
|
||||
const main = !targetSpace.isDefault
|
||||
? targetSpace.groups.find((group) => group.kind === "main")
|
||||
: undefined;
|
||||
if (main) {
|
||||
void openMobileSpace(targetSpace.id, () =>
|
||||
router.push({
|
||||
pathname: "/group-thread",
|
||||
params: { groupId: main.id, name: targetSpace.name },
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
void chooseInboxSpace(targetSpace.id);
|
||||
}}
|
||||
style={({ pressed }) => [styles.spaceSelect, pressed && styles.rowPressed]}
|
||||
>
|
||||
<ProjectTile space={item.space} />
|
||||
<Text style={styles.spaceTitle}>{item.title}</Text>
|
||||
</Pressable>
|
||||
{canDeleteInboxSpace(item.space) ? (
|
||||
@ -601,7 +628,11 @@ export default function Home() {
|
||||
}}
|
||||
style={({ pressed }) => [styles.spaceActions, pressed && styles.rowPressed]}
|
||||
>
|
||||
<NativeSymbol ios="ellipsis" android="ellipsis-horizontal" size={20} />
|
||||
<NativeSymbol
|
||||
ios="ellipsis"
|
||||
android="ellipsis-horizontal"
|
||||
size={mobileShellIconSizes.lg}
|
||||
/>
|
||||
</Pressable>
|
||||
) : null}
|
||||
</View>
|
||||
@ -699,16 +730,17 @@ function ActivitySection({
|
||||
const { t } = useI18n();
|
||||
const router = useRouter();
|
||||
const botsById = useMemo(() => new Map(bots.map((bot) => [bot.id, bot])), [bots]);
|
||||
const openRun = (run: RunActivityRow) => {
|
||||
if (run.groupId) {
|
||||
router.push({
|
||||
pathname: "/group-thread",
|
||||
params: { groupId: run.groupId, name: run.groupName ?? t("Group") },
|
||||
});
|
||||
return;
|
||||
}
|
||||
router.push({ pathname: "/thread", params: { botId: run.botId, name: run.botName } });
|
||||
};
|
||||
const openRun = (run: RunActivityRow) =>
|
||||
void openMobileSpace(run.spaceId, () => {
|
||||
if (run.groupId) {
|
||||
router.push({
|
||||
pathname: "/group-thread",
|
||||
params: { groupId: run.groupId, name: run.groupName ?? t("Group") },
|
||||
});
|
||||
return;
|
||||
}
|
||||
router.push({ pathname: "/thread", params: { botId: run.botId, name: run.botName } });
|
||||
});
|
||||
|
||||
return (
|
||||
<View style={styles.activitySection}>
|
||||
@ -744,6 +776,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,
|
||||
@ -753,7 +846,7 @@ function ActivityRow({
|
||||
bot?: MobileBot;
|
||||
onPress: () => void;
|
||||
}) {
|
||||
const title = run.groupName ? `${run.botName} · ${run.groupName}` : run.botName;
|
||||
const title = runChatTitle(run);
|
||||
const status = activityStatusLabel(run.status);
|
||||
const preview = run.promptSnippet ? `${run.promptSnippet} · ${status}` : status;
|
||||
return (
|
||||
@ -831,6 +924,16 @@ function ConversationRow({
|
||||
);
|
||||
}
|
||||
|
||||
function ProjectTile({ space }: { space: Pick<InboxSpace, "icon" | "color"> }) {
|
||||
const styles = useThemedStyles(createHomeStyles);
|
||||
const { icon, color } = projectLook(space);
|
||||
return (
|
||||
<View style={[styles.projectTile, { backgroundColor: color.hex }]}>
|
||||
<NativeSymbol ios={icon.ios} android={icon.android as never} size={15} color={color.ink} />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function CircleButton({
|
||||
children,
|
||||
onPress,
|
||||
@ -988,9 +1091,9 @@ function createHomeStyles() {
|
||||
gap: 12,
|
||||
},
|
||||
circleButton: {
|
||||
width: 40,
|
||||
height: 40,
|
||||
borderRadius: 20,
|
||||
width: 44,
|
||||
height: 44,
|
||||
borderRadius: 22,
|
||||
backgroundColor: native.fillPressed,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
@ -1116,6 +1219,15 @@ function createHomeStyles() {
|
||||
spaceSelect: {
|
||||
flex: 1,
|
||||
minHeight: 44,
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: 10,
|
||||
},
|
||||
projectTile: {
|
||||
width: 28,
|
||||
height: 28,
|
||||
borderRadius: 8,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
},
|
||||
spaceTitle: {
|
||||
|
||||
@ -5,7 +5,10 @@ import {
|
||||
CONNECTION_CATALOG_PAGE_SIZE,
|
||||
EMPTY_PLUGIN_CATALOG_MESSAGE,
|
||||
filterConnectionCatalogItems,
|
||||
GITHUB_MCP_SOURCE,
|
||||
GITHUB_TOKEN_URL,
|
||||
humanizeToolName,
|
||||
isGithubInstall,
|
||||
} from "@rakazo/core";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import {
|
||||
@ -84,6 +87,11 @@ export default function Integrations() {
|
||||
const [query, setQuery] = useState("");
|
||||
const [visibleCount, setVisibleCount] = useState(CONNECTION_CATALOG_PAGE_SIZE);
|
||||
const [sources, setSources] = useState<CapabilityInstall[]>([]);
|
||||
const github = sources.find(isGithubInstall);
|
||||
const customSources = sources.filter((source) => !isGithubInstall(source));
|
||||
const [githubOpen, setGithubOpen] = useState(false);
|
||||
const [githubToken, setGithubToken] = useState("");
|
||||
const [githubError, setGithubError] = useState<string | null>(null);
|
||||
const [sourceKind, setSourceKind] = useState<SourceKind | null>(null);
|
||||
const [advancedOpen, setAdvancedOpen] = useState(false);
|
||||
const [name, setName] = useState("");
|
||||
@ -388,6 +396,27 @@ export default function Integrations() {
|
||||
}
|
||||
}
|
||||
|
||||
async function connectGithub() {
|
||||
setPending("github");
|
||||
setGithubError(null);
|
||||
try {
|
||||
await rpc("capabilities/install", {
|
||||
kind: "mcp",
|
||||
name: "GitHub",
|
||||
source: GITHUB_MCP_SOURCE,
|
||||
credential: githubToken.trim(),
|
||||
config: { preset: "github" },
|
||||
});
|
||||
setGithubToken("");
|
||||
setGithubOpen(false);
|
||||
await refresh();
|
||||
} catch (reason) {
|
||||
setGithubError(reason instanceof Error ? reason.message : t("Could not connect GitHub"));
|
||||
} finally {
|
||||
setPending(null);
|
||||
}
|
||||
}
|
||||
|
||||
async function removeSource(source: CapabilityInstall) {
|
||||
setPending(source.id);
|
||||
setSourceError(null);
|
||||
@ -592,6 +621,63 @@ export default function Integrations() {
|
||||
renderDetail(detailItem)
|
||||
) : (
|
||||
<>
|
||||
<View testID="github-connect" style={styles.card}>
|
||||
<View style={styles.detailTitleRow}>
|
||||
<ConnectorLogo label="GitHub" styles={styles} />
|
||||
<Text style={[styles.title, styles.grow]}>GitHub</Text>
|
||||
{github ? (
|
||||
<Pressable
|
||||
accessibilityRole="button"
|
||||
disabled={pending === github.id}
|
||||
onPress={() => void removeSource(github)}
|
||||
>
|
||||
<Text style={styles.remove}>{t("Disconnect")}</Text>
|
||||
</Pressable>
|
||||
) : githubOpen ? null : (
|
||||
<Pressable
|
||||
accessibilityRole="button"
|
||||
onPress={() => setGithubOpen(true)}
|
||||
style={styles.smallButton}
|
||||
>
|
||||
<Text style={styles.buttonLabel}>{t("Connect")}</Text>
|
||||
</Pressable>
|
||||
)}
|
||||
</View>
|
||||
{!github && githubOpen ? (
|
||||
<>
|
||||
<Pressable
|
||||
accessibilityRole="link"
|
||||
onPress={() => void Linking.openURL(GITHUB_TOKEN_URL)}
|
||||
style={styles.cardButton}
|
||||
>
|
||||
<Text style={styles.buttonLabel}>{t("Create token on GitHub")}</Text>
|
||||
</Pressable>
|
||||
<TextInput
|
||||
value={githubToken}
|
||||
onChangeText={setGithubToken}
|
||||
accessibilityLabel={t("GitHub token")}
|
||||
placeholder="ghp_…"
|
||||
placeholderTextColor={native.tertiaryLabel}
|
||||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
secureTextEntry
|
||||
style={styles.input}
|
||||
/>
|
||||
<Pressable
|
||||
accessibilityRole="button"
|
||||
disabled={pending === "github" || !githubToken.trim()}
|
||||
onPress={() => void connectGithub()}
|
||||
style={styles.cardButton}
|
||||
>
|
||||
<Text style={styles.buttonLabel}>
|
||||
{pending === "github" ? t("Connecting…") : t("Connect")}
|
||||
</Text>
|
||||
</Pressable>
|
||||
</>
|
||||
) : null}
|
||||
{githubError ? <Text style={styles.error}>{githubError}</Text> : null}
|
||||
</View>
|
||||
|
||||
{!catalogReady ? <ActivityIndicator color={native.fillPressed} /> : null}
|
||||
|
||||
{catalogReady && catalog.length === 0 ? (
|
||||
@ -782,10 +868,10 @@ export default function Integrations() {
|
||||
) : null}
|
||||
|
||||
<Text style={styles.section}>{t("Tool sources")}</Text>
|
||||
{sources.length === 0 ? (
|
||||
{customSources.length === 0 ? (
|
||||
<Text style={styles.secondary}>{t("No custom sources installed.")}</Text>
|
||||
) : null}
|
||||
{sources.map((source) => (
|
||||
{customSources.map((source) => (
|
||||
<View key={source.id} style={styles.row}>
|
||||
<View style={styles.grow}>
|
||||
<Text style={styles.title}>{source.name}</Text>
|
||||
|
||||
@ -931,7 +931,7 @@ export default function Models() {
|
||||
{selected.auth === "oauth" && !subscriptionSignIn ? (
|
||||
<Text style={styles.secondary}>
|
||||
{t(
|
||||
"This subscription sign-in is not available in Rakazo yet. Use a deployment credential or choose another provider.",
|
||||
"This subscription sign-in is not available in evOo yet. Use a deployment credential or choose another provider.",
|
||||
)}
|
||||
</Text>
|
||||
) : null}
|
||||
|
||||
@ -9,7 +9,7 @@ import { Stack, useRouter } from "expo-router";
|
||||
import { useState } from "react";
|
||||
import { Pressable, ScrollView, Text, TextInput } from "react-native";
|
||||
import { ComputerModePicker } from "../components/computer-mode-picker";
|
||||
import { type MobileBot, rpc } from "../lib/api";
|
||||
import { type MobileBot, rpc, selectLibrarySpace } from "../lib/api";
|
||||
import { allowFocusPrompt, scheduleFocusPrompt } from "../lib/focus-prompt";
|
||||
import { useI18n } from "../lib/i18n";
|
||||
import { useMobileTokens } from "../lib/native";
|
||||
@ -42,6 +42,7 @@ export default function NewBot() {
|
||||
setPending(true);
|
||||
setError(null);
|
||||
try {
|
||||
await selectLibrarySpace();
|
||||
// Failed list is unknown — delay focus rather than treating the bot as first.
|
||||
const existing = await rpc<MobileBot[]>("bots/list").catch(() => null);
|
||||
const isFirstBot = existing !== null && existing.length === 0;
|
||||
|
||||
@ -166,9 +166,9 @@ export default function SignIn() {
|
||||
{resetSent
|
||||
? t("Check your email")
|
||||
: mode === "in"
|
||||
? t("Sign in to Rakazo")
|
||||
? t("Sign in to evOo")
|
||||
: mode === "up"
|
||||
? t("Sign up for Rakazo")
|
||||
? t("Sign up for evOo")
|
||||
: t("Reset your password")}
|
||||
</Text>
|
||||
{resetSent ? (
|
||||
@ -457,7 +457,7 @@ function ServerSheet({
|
||||
<Text
|
||||
style={{ color: tokens.mutedForeground, marginTop: 28, fontSize: 15, lineHeight: 22 }}
|
||||
>
|
||||
{t("Enter your Rakazo server address.")}
|
||||
{t("Enter your evOo server address.")}
|
||||
</Text>
|
||||
<TextInput
|
||||
autoCapitalize="none"
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -1,76 +1,51 @@
|
||||
import { voicesForLocale } from "@rakazo/core";
|
||||
import { useFocusEffect } from "expo-router";
|
||||
import { useCallback, useState } from "react";
|
||||
import {
|
||||
ActivityIndicator,
|
||||
Pressable,
|
||||
ScrollView,
|
||||
StyleSheet,
|
||||
Text,
|
||||
TextInput,
|
||||
View,
|
||||
} from "react-native";
|
||||
import { ActivityIndicator, Pressable, ScrollView, StyleSheet, Text, View } from "react-native";
|
||||
import { SafeAreaView } from "react-native-safe-area-context";
|
||||
import { NativeSymbol } from "../components/native-symbol";
|
||||
import { rpc } from "../lib/api";
|
||||
import { mobileTokens } from "../lib/appearance";
|
||||
import { useI18n } from "../lib/i18n";
|
||||
import { native, useThemedStyles } from "../lib/native";
|
||||
import { speakText } from "../lib/voice";
|
||||
import {
|
||||
alignVoiceWithLocale,
|
||||
KOKORO_LOCAL_TOKEN,
|
||||
LOCAL_VOICE_PROVIDER,
|
||||
speakText,
|
||||
} from "../lib/voice";
|
||||
|
||||
const KOKORO_LOCAL_TOKEN = "local-kokoro";
|
||||
|
||||
type VoiceCatalogEntry = {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
transcribe: boolean;
|
||||
};
|
||||
type VoiceCredential = {
|
||||
id: string;
|
||||
provider: string;
|
||||
voiceId: string;
|
||||
};
|
||||
type VoiceStatus = {
|
||||
configured: boolean;
|
||||
ready: boolean;
|
||||
provider: string | null;
|
||||
voiceId: string;
|
||||
};
|
||||
type VoiceInfo = { id: string; label: string; description?: string };
|
||||
|
||||
export default function VoiceSettings() {
|
||||
const styles = useThemedStyles(createVoiceStyles);
|
||||
const { t } = useI18n();
|
||||
const [catalog, setCatalog] = useState<VoiceCatalogEntry[]>([]);
|
||||
const [credentials, setCredentials] = useState<VoiceCredential[]>([]);
|
||||
const [status, setStatus] = useState<VoiceStatus | null>(null);
|
||||
const { locale, t } = useI18n();
|
||||
const [connected, setConnected] = useState(false);
|
||||
const [voices, setVoices] = useState<VoiceInfo[]>([]);
|
||||
const [provider, setProvider] = useState("");
|
||||
const [apiKey, setApiKey] = useState("");
|
||||
const [voiceId, setVoiceId] = useState("");
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [pending, setPending] = useState<"connect" | "disconnect" | "voice" | "test" | null>(null);
|
||||
const [pending, setPending] = useState<"connect" | "disconnect" | "voice" | null>(null);
|
||||
const [playing, setPlaying] = useState<string | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [notice, setNotice] = useState<string | null>(null);
|
||||
|
||||
const load = useCallback(async (nextProvider?: string) => {
|
||||
const [nextCatalog, nextCredentials, nextStatus] = await Promise.all([
|
||||
rpc<VoiceCatalogEntry[]>("voice/catalog"),
|
||||
rpc<VoiceCredential[]>("voice/credentials"),
|
||||
rpc<VoiceStatus>("voice/status"),
|
||||
]);
|
||||
const selected = nextProvider || nextStatus.provider || nextCatalog[0]?.id || "";
|
||||
setCatalog(nextCatalog);
|
||||
setCredentials(nextCredentials);
|
||||
setStatus(nextStatus);
|
||||
setProvider(selected);
|
||||
const cred = nextCredentials.find((entry) => entry.provider === selected);
|
||||
const load = useCallback(async () => {
|
||||
await alignVoiceWithLocale(locale).catch(() => undefined);
|
||||
const credentials = await rpc<VoiceCredential[]>("voice/credentials");
|
||||
const cred = credentials.find((entry) => entry.provider === LOCAL_VOICE_PROVIDER);
|
||||
setConnected(Boolean(cred));
|
||||
setVoiceId(cred?.voiceId ?? "");
|
||||
if (cred) {
|
||||
setVoices(await rpc<VoiceInfo[]>("voice/voices", { provider: selected }));
|
||||
const listed = await rpc<VoiceInfo[]>("voice/voices", { provider: LOCAL_VOICE_PROVIDER });
|
||||
setVoices(voicesForLocale(listed, locale));
|
||||
} else {
|
||||
setVoices([]);
|
||||
}
|
||||
}, []);
|
||||
}, [locale]);
|
||||
|
||||
useFocusEffect(
|
||||
useCallback(() => {
|
||||
@ -83,24 +58,12 @@ export default function VoiceSettings() {
|
||||
}, [load]),
|
||||
);
|
||||
|
||||
const selected = catalog.find((entry) => entry.id === provider);
|
||||
const isKokoro = selected?.id === "kokoro";
|
||||
const credential = credentials.find((entry) => entry.provider === provider);
|
||||
const canConnect = isKokoro || apiKey.trim().length >= 8;
|
||||
|
||||
async function connect() {
|
||||
if (!selected || !canConnect) return;
|
||||
setPending("connect");
|
||||
setError(null);
|
||||
try {
|
||||
await rpc("voice/connect", {
|
||||
provider: selected.id,
|
||||
apiKey: isKokoro ? KOKORO_LOCAL_TOKEN : apiKey.trim(),
|
||||
voiceId: voiceId || undefined,
|
||||
});
|
||||
setApiKey("");
|
||||
await load(selected.id);
|
||||
setNotice(t("Connected {name}.", { name: selected.name }));
|
||||
await rpc("voice/connect", { provider: LOCAL_VOICE_PROVIDER, apiKey: KOKORO_LOCAL_TOKEN });
|
||||
await load();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : t("Could not connect"));
|
||||
} finally {
|
||||
@ -109,14 +72,11 @@ export default function VoiceSettings() {
|
||||
}
|
||||
|
||||
async function disconnect() {
|
||||
if (!credential) return;
|
||||
setPending("disconnect");
|
||||
setError(null);
|
||||
setNotice(null);
|
||||
try {
|
||||
await rpc("voice/disconnect", { provider: credential.provider });
|
||||
setApiKey("");
|
||||
await load(credential.provider);
|
||||
await rpc("voice/disconnect", { provider: LOCAL_VOICE_PROVIDER });
|
||||
await load();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : t("Could not disconnect"));
|
||||
} finally {
|
||||
@ -127,173 +87,145 @@ export default function VoiceSettings() {
|
||||
async function chooseVoice(nextVoiceId: string) {
|
||||
setVoiceId(nextVoiceId);
|
||||
setPending("voice");
|
||||
setError(null);
|
||||
try {
|
||||
await rpc("voice/setVoice", { voiceId: nextVoiceId, provider: selected?.id });
|
||||
await load(selected?.id);
|
||||
await rpc("voice/setVoice", { voiceId: nextVoiceId, provider: LOCAL_VOICE_PROVIDER });
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : t("Could not save that voice"));
|
||||
await load().catch(() => undefined);
|
||||
} finally {
|
||||
setPending(null);
|
||||
}
|
||||
}
|
||||
|
||||
async function testVoice() {
|
||||
setPending("test");
|
||||
async function playVoice(voice: VoiceInfo) {
|
||||
setPlaying(voice.id);
|
||||
setError(null);
|
||||
try {
|
||||
const ready = await speakText(t("Hi, this is how I'll sound when I read replies out loud."));
|
||||
if (!ready) {
|
||||
throw new Error(t("Connect a voice provider first."));
|
||||
}
|
||||
await speakText(t("Hi, this is how I'll sound when I read replies out loud."), {
|
||||
voiceId: voice.id,
|
||||
});
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : t("Could not play a sample"));
|
||||
} finally {
|
||||
setPending(null);
|
||||
setPlaying((current) => (current === voice.id ? null : current));
|
||||
}
|
||||
}
|
||||
|
||||
const busy = pending !== null;
|
||||
|
||||
return (
|
||||
<SafeAreaView edges={["bottom"]} style={styles.screen}>
|
||||
<ScrollView contentContainerStyle={styles.content}>
|
||||
{loading ? <ActivityIndicator color={native.secondaryLabel} /> : null}
|
||||
{error ? <Text style={styles.error}>{error}</Text> : null}
|
||||
{notice ? <Text style={styles.notice}>{notice}</Text> : null}
|
||||
{catalog.map((entry) => {
|
||||
const connected = credentials.some((cred) => cred.provider === entry.id);
|
||||
return (
|
||||
<Pressable
|
||||
key={entry.id}
|
||||
disabled={pending !== null}
|
||||
onPress={() => {
|
||||
setProvider(entry.id);
|
||||
setPending("voice");
|
||||
void load(entry.id)
|
||||
.catch((err: unknown) =>
|
||||
setError(
|
||||
err instanceof Error ? err.message : t("Could not load voice settings"),
|
||||
),
|
||||
)
|
||||
.finally(() => setPending(null));
|
||||
}}
|
||||
style={[
|
||||
styles.card,
|
||||
provider === entry.id && styles.cardActive,
|
||||
pending !== null && styles.disabled,
|
||||
]}
|
||||
>
|
||||
<Text style={styles.cardTitle}>{entry.name}</Text>
|
||||
<Text style={styles.cardMeta}>
|
||||
{connected
|
||||
? t("Connected")
|
||||
: entry.transcribe
|
||||
? t("Speak + transcribe")
|
||||
: t("Speak only")}
|
||||
</Text>
|
||||
</Pressable>
|
||||
);
|
||||
})}
|
||||
{selected ? (
|
||||
<>
|
||||
{isKokoro ? (
|
||||
<Text style={styles.help}>
|
||||
{t("Local voice runs on your server. Tap Connect when it is ready.")}
|
||||
</Text>
|
||||
) : (
|
||||
<TextInput
|
||||
accessibilityLabel={t("API key")}
|
||||
autoCapitalize="none"
|
||||
autoComplete="off"
|
||||
autoCorrect={false}
|
||||
importantForAutofill="no"
|
||||
value={apiKey}
|
||||
onChangeText={setApiKey}
|
||||
placeholder={credential ? t("Paste a replacement key") : t("Paste your API key")}
|
||||
placeholderTextColor={native.tertiaryLabel}
|
||||
secureTextEntry
|
||||
style={styles.input}
|
||||
textContentType="none"
|
||||
/>
|
||||
)}
|
||||
<Pressable
|
||||
disabled={pending !== null || !canConnect}
|
||||
onPress={() => void connect()}
|
||||
style={[styles.button, (pending !== null || !canConnect) && styles.disabled]}
|
||||
>
|
||||
<Text style={styles.buttonLabel}>
|
||||
{credential ? (isKokoro ? t("Reconnect") : t("Replace key")) : t("Connect")}
|
||||
</Text>
|
||||
</Pressable>
|
||||
{credential ? (
|
||||
<Pressable
|
||||
disabled={pending !== null}
|
||||
onPress={() => void disconnect()}
|
||||
style={[styles.secondary, pending !== null && styles.disabled]}
|
||||
>
|
||||
<Text style={styles.secondaryLabel}>
|
||||
{pending === "disconnect" ? t("Disconnecting…") : t("Disconnect")}
|
||||
</Text>
|
||||
</Pressable>
|
||||
) : null}
|
||||
{voices.length ? (
|
||||
<View style={styles.voices}>
|
||||
{voices.map((voice) => (
|
||||
<Pressable
|
||||
key={voice.id}
|
||||
disabled={pending !== null}
|
||||
onPress={() => void chooseVoice(voice.id)}
|
||||
style={[styles.voiceRow, pending !== null && styles.disabled]}
|
||||
>
|
||||
<Text style={styles.voiceLabel}>{voice.label}</Text>
|
||||
{voiceId === voice.id ? <Text style={styles.check}>✓</Text> : null}
|
||||
</Pressable>
|
||||
))}
|
||||
</View>
|
||||
) : null}
|
||||
{status?.ready ? (
|
||||
<Pressable
|
||||
disabled={pending !== null}
|
||||
onPress={() => void testVoice()}
|
||||
style={styles.secondary}
|
||||
>
|
||||
<Text style={styles.secondaryLabel}>{t("Hear a sample")}</Text>
|
||||
</Pressable>
|
||||
) : null}
|
||||
</>
|
||||
{!loading && !connected ? (
|
||||
<Pressable
|
||||
disabled={busy}
|
||||
onPress={() => void connect()}
|
||||
style={[styles.button, busy && styles.disabled]}
|
||||
>
|
||||
<Text style={styles.buttonLabel}>{t("Connect")}</Text>
|
||||
</Pressable>
|
||||
) : null}
|
||||
{voices.length ? (
|
||||
<ScrollView
|
||||
horizontal
|
||||
accessibilityRole="radiogroup"
|
||||
showsHorizontalScrollIndicator={false}
|
||||
snapToInterval={CARD_WIDTH + CARD_GAP}
|
||||
decelerationRate="fast"
|
||||
style={styles.carousel}
|
||||
contentContainerStyle={styles.carouselContent}
|
||||
>
|
||||
{voices.map((voice) => {
|
||||
const active = voiceId === voice.id;
|
||||
return (
|
||||
<Pressable
|
||||
key={voice.id}
|
||||
accessibilityRole="radio"
|
||||
accessibilityState={{ checked: active, disabled: busy }}
|
||||
disabled={busy}
|
||||
onPress={() => void chooseVoice(voice.id)}
|
||||
style={[styles.card, active && styles.cardActive]}
|
||||
>
|
||||
<View style={styles.cardHeader}>
|
||||
<Text numberOfLines={1} style={styles.cardTitle}>
|
||||
{voice.label}
|
||||
</Text>
|
||||
<Pressable
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={t("Play {name}", { name: voice.label })}
|
||||
disabled={playing !== null}
|
||||
hitSlop={8}
|
||||
onPress={() => void playVoice(voice)}
|
||||
style={styles.play}
|
||||
>
|
||||
{playing === voice.id ? (
|
||||
<ActivityIndicator size="small" color={native.secondaryLabel} />
|
||||
) : (
|
||||
<NativeSymbol ios="play.fill" android="play" size={14} />
|
||||
)}
|
||||
</Pressable>
|
||||
</View>
|
||||
{voice.description ? (
|
||||
<Text style={styles.cardMeta}>{voice.description}</Text>
|
||||
) : null}
|
||||
</Pressable>
|
||||
);
|
||||
})}
|
||||
</ScrollView>
|
||||
) : null}
|
||||
{connected ? (
|
||||
<Pressable
|
||||
disabled={busy}
|
||||
onPress={() => void disconnect()}
|
||||
style={[styles.secondary, busy && styles.disabled]}
|
||||
>
|
||||
<Text style={styles.secondaryLabel}>
|
||||
{pending === "disconnect" ? t("Disconnecting…") : t("Disconnect")}
|
||||
</Text>
|
||||
</Pressable>
|
||||
) : null}
|
||||
</ScrollView>
|
||||
</SafeAreaView>
|
||||
);
|
||||
}
|
||||
|
||||
const CARD_WIDTH = 160;
|
||||
const CARD_GAP = 12;
|
||||
|
||||
function createVoiceStyles() {
|
||||
const tokens = mobileTokens();
|
||||
return StyleSheet.create({
|
||||
screen: { flex: 1, backgroundColor: native.page },
|
||||
content: { padding: 20, gap: 10 },
|
||||
error: { color: tokens.destructive, marginBottom: 8 },
|
||||
notice: { color: tokens.success, marginBottom: 8 },
|
||||
content: { paddingVertical: 20, gap: 10 },
|
||||
error: { color: tokens.destructive, marginBottom: 8, marginHorizontal: 20 },
|
||||
carousel: { flexGrow: 0 },
|
||||
carouselContent: { paddingHorizontal: 20, gap: CARD_GAP },
|
||||
card: {
|
||||
width: CARD_WIDTH,
|
||||
borderRadius: 14,
|
||||
borderWidth: 1,
|
||||
borderColor: tokens.border,
|
||||
padding: 14,
|
||||
backgroundColor: tokens.card,
|
||||
},
|
||||
cardActive: { borderColor: tokens.ring, backgroundColor: tokens.muted },
|
||||
cardTitle: { color: native.label, fontSize: 16 },
|
||||
cardMeta: { color: native.tertiaryLabel, marginTop: 4, fontSize: 12 },
|
||||
help: { marginTop: 8, color: native.secondaryLabel, fontSize: 14, lineHeight: 20 },
|
||||
input: {
|
||||
marginTop: 8,
|
||||
borderRadius: 12,
|
||||
borderWidth: 1,
|
||||
borderColor: tokens.border,
|
||||
color: native.label,
|
||||
paddingHorizontal: 14,
|
||||
paddingVertical: 12,
|
||||
backgroundColor: tokens.card,
|
||||
},
|
||||
cardActive: { borderColor: tokens.primary, borderWidth: 2, backgroundColor: tokens.muted },
|
||||
cardHeader: { flexDirection: "row", alignItems: "center", justifyContent: "space-between" },
|
||||
cardTitle: { flexShrink: 1, color: native.label, fontSize: 16, fontWeight: "600" },
|
||||
cardMeta: { color: native.tertiaryLabel, marginTop: 4, fontSize: 13 },
|
||||
play: {
|
||||
width: 30,
|
||||
height: 30,
|
||||
borderRadius: 15,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
backgroundColor: tokens.muted,
|
||||
},
|
||||
button: {
|
||||
marginTop: 8,
|
||||
marginHorizontal: 20,
|
||||
backgroundColor: tokens.primary,
|
||||
borderRadius: 12,
|
||||
paddingVertical: 12,
|
||||
@ -301,17 +233,6 @@ function createVoiceStyles() {
|
||||
},
|
||||
disabled: { opacity: 0.4 },
|
||||
buttonLabel: { color: tokens.primaryForeground, fontWeight: "600" },
|
||||
voices: { marginTop: 12, borderRadius: 12, borderWidth: 1, borderColor: tokens.border },
|
||||
voiceRow: {
|
||||
flexDirection: "row",
|
||||
justifyContent: "space-between",
|
||||
paddingHorizontal: 14,
|
||||
paddingVertical: 12,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: tokens.border,
|
||||
},
|
||||
voiceLabel: { color: native.label },
|
||||
check: { color: tokens.success },
|
||||
secondary: { marginTop: 16, alignItems: "center" },
|
||||
secondaryLabel: { color: native.secondaryLabel, fontSize: 15 },
|
||||
});
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
223
apps/mobile/components/oo-figure.tsx
Normal file
223
apps/mobile/components/oo-figure.tsx
Normal file
@ -0,0 +1,223 @@
|
||||
import type { OoAvatar, OoFrame, OoLife, OoMoment, OoPaint, OoShape } from "@rakazo/core";
|
||||
import {
|
||||
buildOoRig,
|
||||
createOoLife,
|
||||
OO_LIST_MOMENTS,
|
||||
OO_RIG_FACE_VIEWBOX,
|
||||
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 = OO_RIG_FACE_VIEWBOX;
|
||||
/** 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>
|
||||
);
|
||||
}
|
||||
52
apps/mobile/components/run-steps.tsx
Normal file
52
apps/mobile/components/run-steps.tsx
Normal file
@ -0,0 +1,52 @@
|
||||
import { useState } from "react";
|
||||
import { Pressable, Text, View } from "react-native";
|
||||
import type { MobileMessage } from "../lib/api";
|
||||
import { useI18n } from "../lib/i18n";
|
||||
import { useMobileTokens } from "../lib/native";
|
||||
import { NativeSymbol } from "./native-symbol";
|
||||
|
||||
/** A run's earlier step messages, folded behind one line above its latest message. */
|
||||
export function RunSteps({ steps }: { steps: readonly MobileMessage[] }) {
|
||||
const tokens = useMobileTokens();
|
||||
const { t } = useI18n();
|
||||
const [open, setOpen] = useState(false);
|
||||
const label = steps.length === 1 ? t("1 step") : t("{count} steps", { count: steps.length });
|
||||
return (
|
||||
<View style={{ marginBottom: 6 }}>
|
||||
<Pressable
|
||||
accessibilityRole="button"
|
||||
accessibilityState={{ expanded: open }}
|
||||
onPress={() => setOpen((current) => !current)}
|
||||
hitSlop={8}
|
||||
style={{ flexDirection: "row", alignItems: "center", gap: 4, alignSelf: "flex-start" }}
|
||||
>
|
||||
<NativeSymbol
|
||||
ios={open ? "chevron.down" : "chevron.right"}
|
||||
android={open ? "chevron-down" : "chevron-forward"}
|
||||
size={12}
|
||||
color={tokens.mutedForeground}
|
||||
/>
|
||||
<Text style={{ color: tokens.mutedForeground, fontSize: 13.5 }}>{label}</Text>
|
||||
</Pressable>
|
||||
{open ? (
|
||||
<View
|
||||
style={{
|
||||
marginTop: 4,
|
||||
gap: 6,
|
||||
borderLeftWidth: 1,
|
||||
borderLeftColor: tokens.border,
|
||||
paddingLeft: 10,
|
||||
}}
|
||||
>
|
||||
{steps.map((step) => (
|
||||
<Text key={step.id} style={{ color: tokens.mutedForeground, fontSize: 14 }}>
|
||||
{step.blocks
|
||||
.flatMap((block) => (block.kind === "text" ? [block.text] : []))
|
||||
.join("\n")}
|
||||
</Text>
|
||||
))}
|
||||
</View>
|
||||
) : null}
|
||||
</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" },
|
||||
});
|
||||
@ -144,6 +144,16 @@ export function selectedSpaceId(): string | null {
|
||||
return cachedSpaceId || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Agents live in the home library, where a project's coordinator recruits them from; select it
|
||||
* before creating one so the new agent and its chat share the request scope.
|
||||
*/
|
||||
export async function selectLibrarySpace(): Promise<void> {
|
||||
const navigation = await rpc<MobileSpaceNavigation>("spaces/list");
|
||||
const home = navigation.spaces.find((space) => space.isDefault)?.id;
|
||||
if (home && home !== cachedSpaceId) await selectSpace(home);
|
||||
}
|
||||
|
||||
/** Keep requests usable after the server deleted the selected Space but native
|
||||
* storage could not replace it. Neutralize any same-endpoint rollback before
|
||||
* writing the replacement selection so a cleanup failure cannot leave the new
|
||||
@ -740,6 +750,7 @@ export type MobileMe = Pick<
|
||||
| "defaultProvider"
|
||||
| "defaultModel"
|
||||
| "needsModel"
|
||||
| "modelsManaged"
|
||||
| "avatarStyle"
|
||||
| "isDeploymentOwner"
|
||||
>;
|
||||
@ -773,7 +784,7 @@ export type MobileGroup = Pick<
|
||||
| "updatedAt"
|
||||
| "members"
|
||||
> &
|
||||
Partial<Pick<Group, "spaceId">>;
|
||||
Partial<Pick<Group, "spaceId" | "kind">>;
|
||||
|
||||
export type MobileSpace = Space;
|
||||
export type MobileSpaceNavigation = SpaceNavigation;
|
||||
@ -786,8 +797,14 @@ export type MobileSnapshot = {
|
||||
cursor?: number;
|
||||
messages: MobileMessage[];
|
||||
olderCursor: number | null;
|
||||
run: { id: string; botId?: string; status: string; error?: string | null } | null;
|
||||
activeRuns?: Array<{ id: string; botId?: string; status: string }>;
|
||||
run: {
|
||||
id: string;
|
||||
botId?: string;
|
||||
status: string;
|
||||
error?: string | null;
|
||||
createdAt?: string;
|
||||
} | null;
|
||||
activeRuns?: Array<{ id: string; botId?: string; status: string; createdAt?: string }>;
|
||||
members?: MobileGroup["members"];
|
||||
computer?: {
|
||||
kind?: string;
|
||||
@ -901,6 +918,14 @@ export function blockText(message: MobileMessage) {
|
||||
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)` : ""}]`;
|
||||
|
||||
@ -60,6 +60,12 @@ export function mobileTokens(
|
||||
return tokensForAppearance(resolveMobileAppearance(preference, scheme));
|
||||
}
|
||||
|
||||
/** Header and inbox nav glyphs — aligned with web `--icon-*` scale (~25% larger). */
|
||||
export const mobileShellIconSizes = {
|
||||
md: 22,
|
||||
lg: 26,
|
||||
} as const;
|
||||
|
||||
export function subscribeAppearance(listener: () => void): () => void {
|
||||
listeners.add(listener);
|
||||
return () => {
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
@ -1,10 +0,0 @@
|
||||
/**
|
||||
* Re-export shared choreography for Reanimated worklets.
|
||||
* Source of truth: `@rakazo/core` (`avatar-motion.ts`), mirrored by web CSS.
|
||||
*/
|
||||
export {
|
||||
WORKING_AVATAR_DURATIONS_MS,
|
||||
type WorkingAvatarFrame,
|
||||
workingAvatarDuration,
|
||||
workingAvatarFrame,
|
||||
} from "@rakazo/core";
|
||||
@ -79,7 +79,7 @@ export async function probeApiBase(
|
||||
);
|
||||
if (!res.ok) {
|
||||
cancelResponseBody(res);
|
||||
return { ok: false, error: t("That URL did not look like a Rakazo server") };
|
||||
return { ok: false, error: t("That URL did not look like an evOo server") };
|
||||
}
|
||||
let body: HealthResponse;
|
||||
try {
|
||||
@ -93,7 +93,7 @@ export async function probeApiBase(
|
||||
body = {};
|
||||
}
|
||||
if (body.error || body.json?.ok !== true) {
|
||||
return { ok: false, error: t("That URL did not look like a Rakazo server") };
|
||||
return { ok: false, error: t("That URL did not look like an evOo server") };
|
||||
}
|
||||
return parsed;
|
||||
} catch {
|
||||
|
||||
@ -51,7 +51,7 @@ describe("mobile i18n", () => {
|
||||
const { resetI18nForTests, t } = await import("./i18n");
|
||||
resetI18nForTests("zh-CN");
|
||||
expect(t("Account")).toBe("账户");
|
||||
expect(t("Sign in to Rakazo")).toBe("登录 Rakazo");
|
||||
expect(t("Sign in to evOo")).toBe("登录 evOo");
|
||||
expect(t("New bot")).toBe("新建 Bot");
|
||||
expect(t("{runs} runs · {tokens} tokens", { runs: 3, tokens: 12 })).toBe(
|
||||
"3 次运行 · 12 个 token",
|
||||
@ -60,18 +60,26 @@ describe("mobile i18n", () => {
|
||||
|
||||
resetI18nForTests("ru");
|
||||
expect(t("Account")).toBe("Аккаунт");
|
||||
expect(t("Sign in to Rakazo")).toBe("Войти в Rakazo");
|
||||
expect(t("Sign in to evOo")).toBe("Войти в evOo");
|
||||
expect(t("New bot")).toBe("Новый бот");
|
||||
expect(t("{runs} runs · {tokens} tokens", { runs: 3, tokens: 12 })).toBe(
|
||||
"Запусков: 3 · токенов: 12",
|
||||
);
|
||||
expect(t("Delete {name}?", { name: "Scout" })).toBe("Удалить Scout?");
|
||||
|
||||
resetI18nForTests("de");
|
||||
expect(t("Account")).toBe("Konto");
|
||||
expect(t("Sign in to evOo")).toBe("Bei evOo anmelden");
|
||||
expect(t("New bot")).toBe("Neuer Bot");
|
||||
expect(t("{runs} runs · {tokens} tokens", { runs: 3, tokens: 12 })).toBe("3 Läufe · 12 Tokens");
|
||||
expect(t("Delete {name}?", { name: "Scout" })).toBe("Scout löschen?");
|
||||
});
|
||||
|
||||
it("preserves interpolations in the Chinese and Russian catalogs", async () => {
|
||||
it("preserves interpolations in every non-English catalog", async () => {
|
||||
const { ZH_MESSAGES } = await import("./locales/zh");
|
||||
const { RU_MESSAGES } = await import("./locales/ru");
|
||||
for (const messages of [ZH_MESSAGES, RU_MESSAGES]) {
|
||||
const { DE_MESSAGES } = await import("./locales/de");
|
||||
for (const messages of [ZH_MESSAGES, RU_MESSAGES, DE_MESSAGES]) {
|
||||
const empty = Object.entries(messages).filter(([, value]) => !value.trim());
|
||||
const interpolationMismatches = Object.entries(messages).filter(([id, value]) => {
|
||||
const tokens = (message: string) =>
|
||||
@ -83,9 +91,10 @@ describe("mobile i18n", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("translates every mobile chrome t() id in both non-English catalogs", async () => {
|
||||
it("translates every mobile chrome t() id in every non-English catalog", async () => {
|
||||
const { ZH_MESSAGES } = await import("./locales/zh");
|
||||
const { RU_MESSAGES } = await import("./locales/ru");
|
||||
const { DE_MESSAGES } = await import("./locales/de");
|
||||
const { EMPTY_PLUGIN_CATALOG_MESSAGE, SLASH_ACTIONS } = await import("@rakazo/core");
|
||||
const { OPENAI_COMPATIBLE_BASE_URL_HINT } = await import("@rakazo/contracts");
|
||||
const mobileRoot = join(dirname(fileURLToPath(import.meta.url)), "..");
|
||||
@ -104,7 +113,7 @@ describe("mobile i18n", () => {
|
||||
ids.add(JSON.parse(`"${match[1]}"`) as string);
|
||||
}
|
||||
}
|
||||
for (const messages of [ZH_MESSAGES, RU_MESSAGES]) {
|
||||
for (const messages of [ZH_MESSAGES, RU_MESSAGES, DE_MESSAGES]) {
|
||||
const missing = [...ids].filter((id) => !messages[id]?.trim()).sort();
|
||||
expect(missing).toEqual([]);
|
||||
}
|
||||
@ -139,6 +148,17 @@ describe("mobile i18n", () => {
|
||||
expect(dateLocaleForUi()).toBe("ru");
|
||||
});
|
||||
|
||||
it("normalizes a regional German stored locale before activation", async () => {
|
||||
const { getItemAsync } = await import("expo-secure-store");
|
||||
vi.mocked(getItemAsync).mockResolvedValue("de-AT");
|
||||
const { bootstrapI18n, dateLocaleForUi, getActiveUiLocale, t } = await import("./i18n");
|
||||
|
||||
await expect(bootstrapI18n()).resolves.toBe("de");
|
||||
expect(getActiveUiLocale()).toBe("de");
|
||||
expect(t("Language")).toBe("Sprache");
|
||||
expect(dateLocaleForUi()).toBe("de");
|
||||
});
|
||||
|
||||
it("keeps the last locale when rapid setUiLocale calls finish out of order", async () => {
|
||||
const { setItemAsync } = await import("expo-secure-store");
|
||||
const { applyMobileUiDirection } = await import("./ui-direction");
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
import { useSyncExternalStore } from "react";
|
||||
import { DE_MESSAGES } from "./locales/de";
|
||||
import { RU_MESSAGES } from "./locales/ru";
|
||||
import { ZH_MESSAGES } from "./locales/zh";
|
||||
import {
|
||||
@ -9,6 +10,7 @@ import {
|
||||
} from "./ui-locale";
|
||||
|
||||
const catalogs: Partial<Record<UiLocale, Record<string, string>>> = {
|
||||
de: DE_MESSAGES,
|
||||
"zh-CN": ZH_MESSAGES,
|
||||
ru: RU_MESSAGES,
|
||||
};
|
||||
@ -65,7 +67,7 @@ export function activateUiLocale(locale: UiLocale): UiLocale {
|
||||
|
||||
async function applyDirection(locale: UiLocale): Promise<void> {
|
||||
try {
|
||||
// Direction follows the active UI locale only (en / zh-CN / ru → LTR), never the
|
||||
// Direction follows the active UI locale only (en / de / zh-CN / ru → LTR), never the
|
||||
// device language. Applying device RTL here would fight this and reload-loop.
|
||||
const { applyMobileUiDirection } = await import("./ui-direction");
|
||||
applyMobileUiDirection(htmlLangForLocale(locale));
|
||||
|
||||
@ -147,12 +147,18 @@ describe("spaceInboxItems", () => {
|
||||
expect(canDeleteInboxSpace(space({ canDelete }))).toBe(false);
|
||||
});
|
||||
|
||||
it("offers deletion for an explicitly authorized empty non-default space", () => {
|
||||
it("offers deletion for an explicitly authorized non-default space", () => {
|
||||
expect(canDeleteInboxSpace(space())).toBe(true);
|
||||
expect(canDeleteInboxSpace(space({ hasContent: true, canDelete: true }))).toBe(true);
|
||||
});
|
||||
|
||||
it("hides deletion while a space still has content", () => {
|
||||
expect(canDeleteInboxSpace(space({ hasContent: true }))).toBe(false);
|
||||
it("deduplicates same-named projects into a single project entry", () => {
|
||||
const first = space({ id: "space-work-1", name: "Work", hasContent: false });
|
||||
const second = space({ id: "space-work-2", name: " work ", hasContent: true, bots: [bot] });
|
||||
const items = spaceInboxItems([first, second]);
|
||||
const headings = items.filter((item) => item.type === "heading" && item.space);
|
||||
expect(headings).toHaveLength(1);
|
||||
expect(headings[0]).toMatchObject({ key: "space-work-2", title: " work " });
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
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