Compare commits

..

64 Commits

Author SHA1 Message Date
Claude
4f73bac4d1
Merge remote-tracking branch 'origin/main' into claude/gallant-johnson-4lyr66
# Conflicts:
#	ARCHITECTURE.md
2026-10-04 06:49:00 +00:00
Claude
e1226cff9b
feat(media): mobile entry, routes and photo library permission
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NLSCTnhge4umMnemGXWr1w
2026-10-03 19:22:49 +00:00
Claude
ed0cb29e16
Merge branch 'media-w3-mobile' into claude/gallant-johnson-4lyr66 2026-10-03 19:21:59 +00:00
Claude
e8f8912c65
feat(media): wire agent tools and skills, ffmpeg in compose image
Hook the media_* tools and the Video Producer and Video Copy skills into the
executor, pi-runtime activity labels and the skill catalog; all stay hidden
unless Media Studio is configured. Install ffmpeg in the compose image for
renders and drop a local path from ARCHITECTURE.md.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NLSCTnhge4umMnemGXWr1w
2026-10-03 19:21:45 +00:00
Claude
4c30af9ea3
feat(media): mobile library index, resumable upload, and project variants
Index photos and videos from the device library into a client-pushed
ios_library source, upload originals in resumable chunks, and review,
approve, and share project variants with expo-video and the native share
sheet. Pure logic for chunking, resume, indexing, and variants is unit
tested.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NLSCTnhge4umMnemGXWr1w
2026-10-03 19:20:42 +00:00
Claude
80f082f910
Merge branch 'media-w3-agent' into claude/gallant-johnson-4lyr66 2026-10-03 19:18:49 +00:00
Claude
2ca4596539
Merge branch 'media-w3-docs' into claude/gallant-johnson-4lyr66 2026-10-03 19:18:49 +00:00
Claude
1f91a4a727
docs(media): Media Studio guide, license table and shipped platform profiles
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NLSCTnhge4umMnemGXWr1w
2026-10-03 19:17:20 +00:00
Claude
c414c809f3
feat(media): media agent tools, Video Producer and Video Copy skills
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NLSCTnhge4umMnemGXWr1w
2026-10-03 19:12:57 +00:00
Claude
884d8903ca
feat(media): web view skeleton for Media Studio
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NLSCTnhge4umMnemGXWr1w
2026-10-03 19:04:47 +00:00
Claude
b4083844ce
feat(media): wave 2 integration
Composition root shared by API and worker, media job handlers on every job
host, HTTP byte routes, consent re-check before bundling, and auto-fix limited
to changes that keep protection.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NLSCTnhge4umMnemGXWr1w
2026-10-03 19:04:01 +00:00
Claude
90f18cea52
merge: W2 privacy 2026-10-03 18:57:49 +00:00
Claude
d88e0de1bc
wip: media composition root 2026-10-03 18:57:49 +00:00
Claude
8d07ab17d4
feat(media): anonymization, marks, consent, and the render gate
prepareSources applies every face, mark, and region protection before the
compiler runs and fails closed when one cannot be honored: all face modes,
tracking with interpolation and grown holds over lost detections, reframe_out
crops with blur fallback, synthetic identity only through a licensed provider
(blur fallback otherwise), inpainting with a quality floor that falls back to
a cover, and person-bound marks re-found next to the face. The gate runs on
the render output: classifier at the profile rate plus cuts, face tracking
matched to people locally, pixel verification of every recorded protection,
consent and age proof (always for paid video), text and technical checks.
Auto-fix proposes only compliance repairs and stops after three rounds.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NLSCTnhge4umMnemGXWr1w
2026-10-03 18:57:04 +00:00
Claude
2973f5074b
merge: W2 ingest
# Conflicts:
#	apps/desktop/src/preload.test.ts
2026-10-03 18:51:13 +00:00
Claude
21edc4add7
feat(media): wire marketing into the API, add segment results and teaser reset
Brand kits are validated through saveBrandKit and return their warnings;
manual metrics refit the teaser model.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NLSCTnhge4umMnemGXWr1w
2026-10-03 18:50:45 +00:00
Claude
b416ff0fdf
feat(media): ingest job handlers with bounded concurrency and disk backpressure
media.sync, media.proxy, media.analyze, and media.cluster for Media Studio:
incremental metadata sync with deletion detection, 720p CFR proxies with
1 s keyframes, tone mapping, posters and sprites, sidecar analyses stored per
model version with scored segments, and sessions, themes, and person
suggestions. Proxy and analysis work is pumped in a small per-space window,
jobs step aside when their GPU or CPU permit is taken, and a full disk or an
unreachable sidecar pauses work instead of failing assets. Re-clustering
keeps collections the user merged or split. Fixes photo posters, which
never rendered because ffmpeg yields no frame when seeking a single image.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NLSCTnhge4umMnemGXWr1w
2026-10-03 18:50:30 +00:00
Claude
f29e6379cb
merge: W2 worktree-agent-a78be97f0347925a3 2026-10-03 18:47:30 +00:00
Claude
0e82982452
merge: W2 media-w2-render 2026-10-03 18:47:29 +00:00
Claude
227703a78a
merge: W2 worktree-agent-a346544dc365ebb55 2026-10-03 18:47:29 +00:00
Claude
4876c58fca
style(media): format zip test 2026-10-03 18:46:41 +00:00
Claude
7c744b98a0
feat(media): per-segment results and copy checks on every field 2026-10-03 18:46:36 +00:00
Claude
3ebe0fe47b
feat(media): ffmpeg renderer, render job and real render integration test
Fail closed when anonymization cannot prepare every clip, retry a failing
hardware encoder in software, verify each ffmpeg output exists, and fail
renders whose video or poster still carries source metadata. Cover parsing,
encoder detection, two-pass loudness, abort and cleanup, the render job
(auto-fix, cancel, outputs) on memory and Postgres, and a synthetic
16:9 to 9:16 render plus a region blur check with ffprobe.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NLSCTnhge4umMnemGXWr1w
2026-10-03 18:44:58 +00:00
Claude
03e4961c9f
test(media): cover marketing plans, brand kits, bundles, publishing and learning 2026-10-03 18:43:37 +00:00
Claude
ffcc47710d
test(media): type the media router test client 2026-10-03 18:39:28 +00:00
Claude
e601b26292
feat(media): media API procedures, chunked uploads, and authorization tests 2026-10-03 18:37:51 +00:00
Claude
872fdd2194
feat(media): marketing service, brand kits, intro variants and bundle publisher 2026-10-03 18:36:53 +00:00
Claude
87d5be4daf
fix(desktop): iterate Photos persistent changes as a sequence
PHPersistentChangeFetchResult has no enumerateChanges; it is a Swift sequence.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NLSCTnhge4umMnemGXWr1w
2026-10-03 18:31:45 +00:00
Claude
44396969fc
merge: main into media studio
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NLSCTnhge4umMnemGXWr1w
2026-10-03 18:30:25 +00:00
Claude
393137be9a
Merge remote-tracking branch 'origin/media-wip/w2-render' into media-w2-render 2026-10-03 18:29:04 +00:00
Claude
4721f00546
Merge remote-tracking branch 'origin/media-wip/w2-api' into worktree-agent-a346544dc365ebb55 2026-10-03 18:28:41 +00:00
Claude
d6957c4670
Merge remote-tracking branch 'origin/media-wip/w2-privacy' into worktree-agent-ac408b7c790dd9920 2026-10-03 18:28:38 +00:00
Claude
439de0d936
merge: media base b4222ac 2026-10-03 18:28:20 +00:00
Claude
3c13961948
Merge remote-tracking branch 'origin/media-wip/w2-marketing' into worktree-agent-a78be97f0347925a3 2026-10-03 18:28:15 +00:00
Claude
f4bf545c45
Merge remote-tracking branch 'origin/media-wip/w2-ingest' into worktree-agent-afca57be83bd9f7ba 2026-10-03 18:28:03 +00:00
Claude
7f6fc678c3
wip(media): w2-privacy partial work (handover, unverified)
Stopped mid-implementation for the handover to the project thread.
Not typechecked or tested; do not merge as is.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0138YqDj3ttfayRwuMKKrqVj
2026-10-03 18:24:55 +00:00
Claude
3f9a601dba
wip(media): w2-marketing partial work (handover, unverified)
Stopped mid-implementation for the handover to the project thread.
Not typechecked or tested; do not merge as is.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0138YqDj3ttfayRwuMKKrqVj
2026-10-03 18:24:53 +00:00
Claude
8f38c22119
wip(media): w2-render partial work (handover, unverified)
Stopped mid-implementation for the handover to the project thread.
Not typechecked or tested; do not merge as is.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0138YqDj3ttfayRwuMKKrqVj
2026-10-03 18:24:51 +00:00
Claude
239a1eceb0
wip(media): w2-api partial work (handover, unverified)
Stopped mid-implementation for the handover to the project thread.
Not typechecked or tested; do not merge as is.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0138YqDj3ttfayRwuMKKrqVj
2026-10-03 18:24:49 +00:00
Claude
55e129cb7e
wip(media): w2-ingest partial work (handover, unverified)
Stopped mid-implementation for the handover to the project thread.
Not typechecked or tested; do not merge as is.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0138YqDj3ttfayRwuMKKrqVj
2026-10-03 18:24:47 +00:00
Claude
b4222ac5ec
feat(media): compose profile and CI job for the media sidecar
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0138YqDj3ttfayRwuMKKrqVj
2026-10-03 18:21:16 +00:00
Claude
bd5f637501
merge: W1-sidecar local media inference sidecar
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0138YqDj3ttfayRwuMKKrqVj
2026-10-03 18:20:41 +00:00
Claude
5e2094f2ad
feat(media): local inference sidecar for Media Studio
FastAPI service in infra/media-worker implementing every
MediaInferenceProvider operation behind HTTP API v1, plus capabilities.
Bearer auth, MEDIA_ROOT realpath enforcement, strict request schemas,
cancellation on disconnect, GPU/CPU gates with 503 overflow, input
size/duration limits, lazy SHA256-pinned model downloads, fail-closed
redaction with atomic output, offline pytest suite with fake backends,
CPU/CUDA/ROCm Dockerfile and a license review of every model.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0138YqDj3ttfayRwuMKKrqVj
2026-10-03 18:19:47 +00:00
Claude
a40582165a
feat(media): wire Apple Photos sync into the desktop app
Move the Photos bridge types into the desktop contract, expose
rakazoDesktop.media.photos from the preload, connect the sync runner in the
main process through an authenticated oRPC client, package and sign the
helper, and build and test it on macOS CI.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0138YqDj3ttfayRwuMKKrqVj
2026-10-03 18:15:53 +00:00
Claude
2556ed197e
feat(media): service contracts between media slices
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0138YqDj3ttfayRwuMKKrqVj
2026-10-03 18:11:04 +00:00
Claude
475710e27e
feat(media): shared service deps and consent-gated model client
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0138YqDj3ttfayRwuMKKrqVj
2026-10-03 18:10:02 +00:00
Claude
d43e374534
merge: W1-domain media domain logic
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0138YqDj3ttfayRwuMKKrqVj
2026-10-03 18:06:39 +00:00
Claude
a6598f09d9
test(media): cover clustering, EDL, compiler, teaser, gate and marketing
Includes compiler snapshots, injection-proof escaping checks, and an
ffmpeg integration test on synthetic testsrc/sine inputs that is skipped
when ffmpeg is not installed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0138YqDj3ttfayRwuMKKrqVj
2026-10-03 18:06:02 +00:00
Claude
26d95a374e
feat(media): implement pure domain logic for Media Studio
Session and theme clustering, EDL validation and timeline mapping, the
EDL-to-ffmpeg compiler with caption files and filtergraph escaping,
teaser selection and learning, the compliance gate with compliance-only
auto-fix, and the marketing helpers (UTM, hashtags, CSV metrics, intro
proposals, brand-kit checks, upload checklist).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0138YqDj3ttfayRwuMKKrqVj
2026-10-03 18:06:02 +00:00
Claude
1aab5ab3a8
feat(media): expose asset externalId, filter by ingest state, push device deletions
The desktop Photos sync needs to map awaiting assets back to Photos items,
query only awaiting uploads, and report deletions without a full relisting.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0138YqDj3ttfayRwuMKKrqVj
2026-10-03 18:00:02 +00:00
Claude
51445e9442
merge: W1-desktop Apple Photos helper and sync
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0138YqDj3ttfayRwuMKKrqVj
2026-10-03 17:57:20 +00:00
Claude
0ee6c664d2
build(db): depend on @rakazo/media for coordinate rounding
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0138YqDj3ttfayRwuMKKrqVj
2026-10-03 17:57:02 +00:00
Claude
c877343218
merge: W1-db media repositories
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0138YqDj3ttfayRwuMKKrqVj
2026-10-03 17:56:32 +00:00
Claude
d3c6f262ad
feat(media): Apple Photos helper and client-pushed desktop sync
Adds the macOS photos-helper Swift CLI (PhotoKit listing with stable
cursors, persistent change tokens, iCloud-aware original export with a
path guard and SHA-256) plus XCTests for its PhotoKit-free core, and the
Electron main-process side: a validated helper client, a resumable
metadata push and chunked original upload against the media contract,
and IPC handlers that stay unavailable off macOS.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0138YqDj3ttfayRwuMKKrqVj
2026-10-03 17:56:14 +00:00
Claude
5e155a0850
test(media): Postgres and unit tests for the media repositories
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0138YqDj3ttfayRwuMKKrqVj
2026-10-03 17:55:49 +00:00
Claude
c1c39b6e46
feat(media): space-scoped Media Studio repositories
Sources, assets with keyset search, analyses, segments, uploads,
collections with re-clustering, people with consent and complete
biometric removal, marks, brand kits, projects with versioned plans,
renders with atomic claims, publications, metrics and the funnel.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0138YqDj3ttfayRwuMKKrqVj
2026-10-03 17:55:49 +00:00
Claude
c3f06e4a12
feat(media): wire media adapters and ffmpeg for CI media tests
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0138YqDj3ttfayRwuMKKrqVj
2026-10-03 17:53:43 +00:00
Claude
0c4e0202f3
merge: W1-adapters emulator, sidecar client, folder source
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0138YqDj3ttfayRwuMKKrqVj
2026-10-03 17:52:59 +00:00
Claude
270ea8068a
feat(media): inference emulator, sidecar client, folder source and factory
Add the Media Studio adapters behind the frozen provider contracts: a
deterministic offline inference emulator driven by file-name markers that
renders real redactions with ffmpeg, a synthetic emulator library, the
bearer-auth HTTP client for the local media sidecar with validated
responses, typed errors, busy retries and timeouts, a lazily paginated
folder source that never follows symlinks, and the configuration factory.
A shared conformance suite runs against the emulator and, in the Docker
integration job, against the sidecar.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0138YqDj3ttfayRwuMKKrqVj
2026-10-03 17:52:17 +00:00
Claude
f98fd9703c
feat(media): export motion graphics renderer
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0138YqDj3ttfayRwuMKKrqVj
2026-10-03 17:48:29 +00:00
Claude
759862fef4
merge: W1-motion motion graphics renderer
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0138YqDj3ttfayRwuMKKrqVj
2026-10-03 17:47:57 +00:00
Claude
b5cef21f44
feat(media): resvg motion graphics renderer with brand-kit templates
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0138YqDj3ttfayRwuMKKrqVj
2026-10-03 17:47:24 +00:00
Claude
7ccc96ac11
feat(media): shared geo, path-safety and profile loading helpers
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0138YqDj3ttfayRwuMKKrqVj
2026-10-03 17:34:04 +00:00
Claude
a663764ee8
feat(media): contracts and foundation for Media Studio
Freeze the shared contracts every Media Studio slice builds on: zod DTOs,
EDL, platform profile, brand kit, gate and render plan schemas; the media
RPC surface with a scaffold router; provider interfaces for sources,
inference, rendering, motion graphics and publishing; media background
jobs; the Prisma models and migration; and the @rakazo/media domain
package signatures.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0138YqDj3ttfayRwuMKKrqVj
2026-10-03 17:32:52 +00:00
1164 changed files with 104270 additions and 163271 deletions

View File

@ -25,9 +25,9 @@ PUBLIC_POSTHOG_KEY=
PUBLIC_POSTHOG_HOST=https://us.i.posthog.com PUBLIC_POSTHOG_HOST=https://us.i.posthog.com
SIGNUPS_ENABLED=true SIGNUPS_ENABLED=true
SIGNUP_ALLOWLIST= SIGNUP_ALLOWLIST=
# First-run discovery. Leave unset and onboarding never looks at the host filesystem. # First-run discovery. Leave unset and the onboarding scan never touches the host filesystem.
# Set a folder (for example a home folder) and the deployment owner's onboarding offers to list the # Set a home-style directory (its Projects, Documents, src, ... subfolders are scanned) to let the
# git projects in it, reading file and folder names only. Other accounts never see the step. # deployment owner's own onboarding discover local projects. Other accounts never scan the host.
# ONBOARDING_SCAN_HOME= # ONBOARDING_SCAN_HOME=
ENCRYPTION_KEY=replace-with-64-random-hex-characters ENCRYPTION_KEY=replace-with-64-random-hex-characters
DATA_DIR=./data DATA_DIR=./data
@ -99,12 +99,8 @@ PI_DEFAULT_PROVIDER=openrouter
# Computer-use tests can override this with COMPUTER_E2E_MODEL. # Computer-use tests can override this with COMPUTER_E2E_MODEL.
PI_DEFAULT_MODEL=openai/gpt-5.6-luna PI_DEFAULT_MODEL=openai/gpt-5.6-luna
# Optional local OpenAI-compatible models. Leave blank to disable the provider. # 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=
RAKAZO_LOCAL_MODELS_URL=http://127.0.0.1:11434/v1 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_CONTEXT_WINDOW=32768
RAKAZO_LOCAL_MAX_TOKENS=4096 RAKAZO_LOCAL_MAX_TOKENS=4096
# Allow user-connected OpenAI-compatible endpoints on public hostnames (default: private/loopback only). # Allow user-connected OpenAI-compatible endpoints on public hostnames (default: private/loopback only).
@ -170,12 +166,8 @@ SMTP_URL=
EMAIL_FROM= EMAIL_FROM=
# Local development only: capture password-reset email and print its link to the API console. # Local development only: capture password-reset email and print its link to the API console.
EMAIL_EMULATOR= EMAIL_EMULATOR=
# Optional web push keys (set both). Unset: generated once and stored encrypted in the database.
# Browsers only allow push on https (or localhost).
VAPID_PUBLIC_KEY= VAPID_PUBLIC_KEY=
VAPID_PRIVATE_KEY= VAPID_PRIVATE_KEY=
# Contact for push services: mailto: or https: URL. Defaults to WEB_ORIGIN when it is https.
VAPID_SUBJECT=
# Structured logs to stdout (json in production, pretty in development unless overridden). # Structured logs to stdout (json in production, pretty in development unless overridden).
LOG_LEVEL=info LOG_LEVEL=info
LOG_FORMAT= LOG_FORMAT=
@ -206,10 +198,10 @@ RAKAZO_COMPOSE_FILE=
# RAKAZO_UPDATE_SERVICES=supervisor # RAKAZO_UPDATE_SERVICES=supervisor
RAKAZO_UPDATE_SERVICES= RAKAZO_UPDATE_SERVICES=
# Published images (defaults match this repository). Tag `local` is built from the checkout. # Published images (defaults match this repository). Tag `local` is built from the checkout.
RAKAZO_IMAGE=ghcr.io/cynarai/evoo-local/app RAKAZO_IMAGE=ghcr.io/elie222/rakazo/app
RAKAZO_IMAGE_TAG=local RAKAZO_IMAGE_TAG=local
RAKAZO_IMAGE_TAG_PREVIOUS= RAKAZO_IMAGE_TAG_PREVIOUS=
RAKAZO_UPDATER_IMAGE=ghcr.io/cynarai/evoo-local/updater RAKAZO_UPDATER_IMAGE=ghcr.io/elie222/rakazo/updater
RAKAZO_UPDATER_IMAGE_TAG=local RAKAZO_UPDATER_IMAGE_TAG=local
# Required only when starting the opt-in `updater` Compose profile; the sidecar refuses to start # 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, # without it. Use a dedicated random value, not BETTER_AUTH_SECRET,
@ -224,3 +216,15 @@ RAKAZO_UPDATER_TOKEN=
# Expo: English or Simplified Chinese (en | zh-CN). Overridden by SecureStore key # Expo: English or Simplified Chinese (en | zh-CN). Overridden by SecureStore key
# rakazo.uiLocale from Account → Language. # rakazo.uiLocale from Account → Language.
# EXPO_PUBLIC_DEFAULT_UI_LOCALE=en # EXPO_PUBLIC_DEFAULT_UI_LOCALE=en
# Optional Media Studio worker (local ffmpeg + ML sidecar, compose profile "media").
# Without it, Media Studio stays hidden and media jobs are skipped.
# Compose: http://media-worker:8790. Bare-metal dev: http://127.0.0.1:8790.
# MEDIA_WORKER_URL=http://media-worker:8790
# At least 32 random characters.
# MEDIA_WORKER_TOKEN=REPLACE_WITH_RANDOM_TOKEN
# Private platform profiles that override the shipped ones. Keep outside the repo.
# MEDIA_PROFILES_DIR=~/.evoo/media-profiles
# Media jobs per worker process: model inference (GPU) and ffmpeg work (CPU).
# MEDIA_GPU_CONCURRENCY=1
# MEDIA_CPU_CONCURRENCY=2

View File

@ -1,5 +1,5 @@
name: Bug report name: Bug report
description: Something is broken in evOo description: Something is broken in Rakazo
title: "[Bug]: " title: "[Bug]: "
labels: labels:
- bug - bug
@ -7,7 +7,7 @@ body:
- type: markdown - type: markdown
attributes: attributes:
value: | value: |
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). For **security vulnerabilities**, email [security@rakazo.com](mailto:security@rakazo.com) — do not use this form. See [SECURITY.md](https://github.com/elie222/rakazo/blob/main/SECURITY.md).
- type: checkboxes - type: checkboxes
id: not-security id: not-security
@ -55,7 +55,7 @@ body:
- type: input - type: input
id: version id: version
attributes: attributes:
label: evOo version or commit label: Rakazo version or commit
placeholder: v0.1.0 or abc1234 placeholder: v0.1.0 or abc1234
validations: validations:
required: true required: true

View File

@ -1,5 +1,11 @@
blank_issues_enabled: false blank_issues_enabled: false
contact_links: contact_links:
- name: Security vulnerabilities - name: Security vulnerabilities
url: https://github.com/cynarAI/evOo-local/security/advisories/new url: https://github.com/elie222/rakazo/blob/main/SECURITY.md
about: Report security issues privately. Do not open a public issue. 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.

View File

@ -1,5 +1,5 @@
name: Self-host setup name: Self-host setup
description: Cannot start evOo, run compose, migrate, or build the sandbox description: Cannot start Rakazo, run compose, migrate, or build the sandbox
title: "[Self-host]: " title: "[Self-host]: "
labels: labels:
- self-host - self-host
@ -7,9 +7,9 @@ body:
- type: markdown - type: markdown
attributes: attributes:
value: | value: |
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). For **security vulnerabilities**, email [security@rakazo.com](mailto:security@rakazo.com). See [SECURITY.md](https://github.com/elie222/rakazo/blob/main/SECURITY.md).
Setup steps are in [README.md](https://github.com/cynarAI/evOo-local/blob/main/README.md). Do not paste `.env` contents or API keys. Setup steps are in [README.md](https://github.com/elie222/rakazo/blob/main/README.md). Do not paste `.env` contents or API keys.
- type: dropdown - type: dropdown
id: step id: step
@ -46,7 +46,7 @@ body:
- type: input - type: input
id: version id: version
attributes: attributes:
label: evOo version or commit label: Rakazo version or commit
placeholder: main @ abc1234 placeholder: main @ abc1234
validations: validations:
required: true required: true

View File

@ -24,7 +24,7 @@ These workflows may fail without blocking product delivery. Do **not** add them
| Check / workflow | Why infra | | Check / workflow | Why infra |
| --- | --- | | --- | --- |
| `macOS setup screenshot (infra)` (`desktop-macos-screenshot.yml`) | Native screenshot capture; skips when the runner has no display | | `macOS setup screenshot (infra)` (`desktop-macos-screenshot.yml`) | Native screenshot capture; skips when the runner has no display |
| Playwright report handoff | Soft-fail (`continue-on-error`); the report stays on the evo runner host for `publish Playwright report` | | Playwright / desktop artifact uploads | Soft-fail (`continue-on-error`) when GitHub artifact quota is exhausted |
| `publish-server-image.yml` (PR paths) | Docker image validation for infra changes | | `publish-server-image.yml` (PR paths) | Docker image validation for infra changes |
| `mobile Android screenshots` | Manual `workflow_dispatch` only | | `mobile Android screenshots` | Manual `workflow_dispatch` only |
| `nightly verification` | Scheduled visual regression | | `nightly verification` | Scheduled visual regression |

View File

@ -71,10 +71,7 @@ jobs:
HEAD_SHA: ${{ steps.pr.outputs.head_sha }} HEAD_SHA: ${{ steps.pr.outputs.head_sha }}
run: | run: |
set -euo pipefail set -euo pipefail
conclusions=() mapfile -t conclusions < <(
while IFS= read -r conclusion; do
[[ -n "$conclusion" ]] && conclusions+=("$conclusion")
done < <(
gh api "repos/${GITHUB_REPOSITORY}/commits/${HEAD_SHA}/check-runs" --paginate \ gh api "repos/${GITHUB_REPOSITORY}/commits/${HEAD_SHA}/check-runs" --paginate \
--jq '.check_runs[] | select(.name == "Product CI") | .conclusion' --jq '.check_runs[] | select(.name == "Product CI") | .conclusion'
) )

View File

@ -5,87 +5,19 @@ name: ci
on: on:
push: push:
branches: [main] 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: pull_request:
permissions: permissions:
contents: read contents: read
pull-requests: read
concurrency: concurrency:
group: ci-${{ github.event.pull_request.number || github.ref }} group: ci-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }} 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: 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: lint:
name: Lint name: Lint
needs: changes runs-on: [self-hosted, evoo-ci, Linux]
if: ${{ (needs.changes.result == 'success' && needs.changes.outputs.product == 'true') || needs.changes.result != 'success' }}
runs-on: [self-hosted, evoo-ci]
timeout-minutes: 20
steps: steps:
- uses: actions/checkout@v5 - uses: actions/checkout@v5
with: with:
@ -113,15 +45,13 @@ jobs:
- uses: actions/setup-node@v4 - uses: actions/setup-node@v4
with: with:
node-version: 22 node-version: 22
cache: pnpm
- run: pnpm install --frozen-lockfile - run: pnpm install --frozen-lockfile
- run: pnpm lint - run: pnpm lint
check: check:
name: Typecheck name: Typecheck
needs: changes runs-on: [self-hosted, evoo-ci, Linux]
if: ${{ (needs.changes.result == 'success' && needs.changes.outputs.product == 'true') || needs.changes.result != 'success' }}
runs-on: [self-hosted, evoo-ci]
timeout-minutes: 20
steps: steps:
- uses: actions/checkout@v5 - uses: actions/checkout@v5
with: with:
@ -149,6 +79,7 @@ jobs:
- uses: actions/setup-node@v4 - uses: actions/setup-node@v4
with: with:
node-version: 22 node-version: 22
cache: pnpm
- run: pnpm install --frozen-lockfile - run: pnpm install --frozen-lockfile
- run: pnpm db:generate - run: pnpm db:generate
env: env:
@ -157,8 +88,6 @@ jobs:
build: build:
name: Production builds 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] runs-on: [self-hosted, evoo-ci, Linux]
timeout-minutes: 10 timeout-minutes: 10
steps: steps:
@ -188,6 +117,7 @@ jobs:
- uses: actions/setup-node@v4 - uses: actions/setup-node@v4
with: with:
node-version: 22 node-version: 22
cache: pnpm
- run: pnpm install --frozen-lockfile - run: pnpm install --frozen-lockfile
- run: pnpm build - run: pnpm build
env: env:
@ -196,13 +126,18 @@ jobs:
ENCRYPTION_KEY: ci-build-encryption-key-at-least-32-characters ENCRYPTION_KEY: ci-build-encryption-key-at-least-32-characters
- name: Electron smoke - name: Electron smoke
run: xvfb-run --auto-servernum pnpm --filter @rakazo/desktop exec playwright test --config e2e/playwright.config.ts 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: test:
name: Unit tests 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] runs-on: [self-hosted, evoo-ci, Linux]
timeout-minutes: 45
steps: steps:
- uses: actions/checkout@v5 - uses: actions/checkout@v5
with: with:
@ -230,16 +165,24 @@ jobs:
- uses: actions/setup-node@v4 - uses: actions/setup-node@v4
with: with:
node-version: 22 node-version: 22
cache: pnpm
- run: pnpm install --frozen-lockfile - run: pnpm install --frozen-lockfile
- run: pnpm db:generate - run: pnpm db:generate
env: env:
DATABASE_URL: postgres://rakazo:rakazo@127.0.0.1:5433/rakazo DATABASE_URL: postgres://rakazo:rakazo@127.0.0.1:5433/rakazo
- name: Ensure ffmpeg for media tests
shell: bash
run: |
if command -v ffmpeg >/dev/null && command -v ffprobe >/dev/null; then exit 0; fi
if sudo -n true 2>/dev/null; then
sudo apt-get update && sudo apt-get install -y --no-install-recommends ffmpeg
else
echo "::warning::ffmpeg is missing on this runner; media render tests will be skipped"
fi
- run: pnpm test - run: pnpm test
test-integration: test-integration:
name: Postgres journeys 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] runs-on: [self-hosted, evoo-ci, Linux]
timeout-minutes: 10 timeout-minutes: 10
steps: steps:
@ -269,28 +212,24 @@ jobs:
- uses: actions/setup-node@v4 - uses: actions/setup-node@v4
with: with:
node-version: 22 node-version: 22
cache: pnpm
- run: pnpm install --frozen-lockfile - run: pnpm install --frozen-lockfile
- run: pnpm test:integration - run: pnpm test:integration
test-e2e: test-e2e:
name: Web 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 uses: ./.github/workflows/playwright.yml
with: with:
hand_off_report: true upload_artifacts: true
product-gate: product-gate:
name: Product CI name: Product CI
if: always() if: always()
needs: [changes, lint, check, build, test, test-integration, test-e2e] needs: [lint, check, build, test, test-integration, test-e2e]
runs-on: [self-hosted, evoo-ci] runs-on: [self-hosted, evoo-ci, Linux]
timeout-minutes: 5
steps: steps:
- name: Verify product checks - name: Verify product checks
env: env:
CHANGES: ${{ needs.changes.result }}
PRODUCT: ${{ needs.changes.outputs.product }}
LINT: ${{ needs.lint.result }} LINT: ${{ needs.lint.result }}
CHECK: ${{ needs.check.result }} CHECK: ${{ needs.check.result }}
BUILD: ${{ needs.build.result }} BUILD: ${{ needs.build.result }}
@ -299,23 +238,11 @@ jobs:
TEST_E2E: ${{ needs.test-e2e.result }} TEST_E2E: ${{ needs.test-e2e.result }}
run: | run: |
set -euo pipefail 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 failed=0
for name in LINT CHECK BUILD TEST TEST_INTEGRATION TEST_E2E; do for name in LINT CHECK BUILD TEST TEST_INTEGRATION TEST_E2E; do
result="${!name}" result="${!name}"
printf '%s=%s\n' "$name" "$result" printf '%s=%s\n' "$name" "$result"
if [[ "$result" == "skipped" ]]; then if [[ "$result" != "success" ]]; then
echo "$name was skipped although product paths changed." >&2
failed=1
elif [[ "$result" != "success" ]]; then
failed=1 failed=1
fi fi
done done
@ -327,9 +254,9 @@ jobs:
publish-mobile-update: publish-mobile-update:
name: Publish compatible mobile update name: Publish compatible mobile update
if: github.event_name == 'push' && github.ref == 'refs/heads/main' && needs.changes.outputs.product == 'true' if: github.event_name == 'push' && github.ref == 'refs/heads/main'
continue-on-error: true continue-on-error: true
needs: [changes, lint, check, build, test, test-integration, test-e2e] needs: [lint, check, build, test, test-integration, test-e2e]
runs-on: [self-hosted, evoo-ci, Linux] runs-on: [self-hosted, evoo-ci, Linux]
timeout-minutes: 20 timeout-minutes: 20
concurrency: concurrency:
@ -470,6 +397,7 @@ jobs:
if: steps.ota.outputs.publish == 'true' && steps.expo_token.outputs.ready == 'true' if: steps.ota.outputs.publish == 'true' && steps.expo_token.outputs.ready == 'true'
with: with:
node-version: 24 node-version: 24
cache: pnpm
- name: Set up Expo - name: Set up Expo
if: steps.ota.outputs.publish == 'true' && steps.expo_token.outputs.ready == 'true' if: steps.ota.outputs.publish == 'true' && steps.expo_token.outputs.ready == 'true'
uses: expo/expo-github-action@eab7a230208c952974db8c3245cfd78402c7b385 # v9 uses: expo/expo-github-action@eab7a230208c952974db8c3245cfd78402c7b385 # v9
@ -477,9 +405,6 @@ jobs:
eas-version: latest eas-version: latest
packager: pnpm packager: pnpm
token: ${{ secrets.EXPO_TOKEN }} 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 - name: Install dependencies
if: steps.ota.outputs.publish == 'true' && steps.expo_token.outputs.ready == 'true' if: steps.ota.outputs.publish == 'true' && steps.expo_token.outputs.ready == 'true'
run: pnpm install --frozen-lockfile run: pnpm install --frozen-lockfile
@ -506,3 +431,46 @@ jobs:
fi fi
eas update --platform all --channel production --environment production --message "$GITHUB_SHA" --non-interactive 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

View File

@ -1,82 +0,0 @@
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

View File

@ -50,6 +50,7 @@ jobs:
if: steps.display.outputs.available == 'true' if: steps.display.outputs.available == 'true'
with: with:
node-version: 22 node-version: 22
cache: pnpm
- run: pnpm install --frozen-lockfile - run: pnpm install --frozen-lockfile
if: steps.display.outputs.available == 'true' if: steps.display.outputs.available == 'true'
- run: pnpm --filter @rakazo/desktop build - run: pnpm --filter @rakazo/desktop build
@ -76,3 +77,14 @@ jobs:
pnpm --filter @rakazo/desktop exec playwright test pnpm --filter @rakazo/desktop exec playwright test
--config e2e/playwright.config.ts setup.spec.ts --config e2e/playwright.config.ts setup.spec.ts
--grep "first run asks whether to use a local or existing instance" --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

View File

@ -0,0 +1,32 @@
name: desktop-photos-helper
on:
pull_request:
paths: &photos-helper-paths
- ".github/workflows/desktop-photos-helper.yml"
- "apps/desktop/native/photos-helper/**"
push:
branches: [main]
paths: *photos-helper-paths
permissions:
contents: read
concurrency:
group: desktop-photos-helper-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
jobs:
swift:
name: Photos helper build and XCTest
runs-on: [self-hosted, evoo-ci, macOS]
timeout-minutes: 20
steps:
- uses: actions/checkout@v5
with:
persist-credentials: false
- name: Build and test
working-directory: apps/desktop/native/photos-helper
run: |
set -euo pipefail
swift build -c release
swift test

32
.github/workflows/media-worker.yml vendored Normal file
View File

@ -0,0 +1,32 @@
name: media-worker
on:
pull_request:
paths: &media-worker-paths
- ".github/workflows/media-worker.yml"
- "infra/media-worker/**"
push:
branches: [main]
paths: *media-worker-paths
permissions:
contents: read
concurrency:
group: media-worker-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
jobs:
sidecar:
name: Media sidecar
runs-on: [self-hosted, evoo-ci, Linux]
timeout-minutes: 30
steps:
- uses: actions/checkout@v5
with:
persist-credentials: false
- name: Build test image (CPU)
run: docker build --build-arg ACCELERATOR=cpu --target test -t rakazo/media-worker:ci-test infra/media-worker
- name: Pytest (offline, fake model backends)
run: docker run --rm --network none rakazo/media-worker:ci-test
- name: Build runtime image (CPU)
run: docker build --build-arg ACCELERATOR=cpu -t rakazo/media-worker:ci infra/media-worker

View File

@ -39,39 +39,41 @@ jobs:
with: with:
persist-credentials: false persist-credentials: false
# Runner-host caches only: never GitHub's cache or artifact storage.
- name: Restore screenshot APK - name: Restore screenshot APK
id: screenshot-apk id: screenshot-apk
env: uses: actions/cache/restore@0400d5f644dc74513175e3cd8d07132dd4860809 # v4.2.4
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/**') }} with:
run: | path: apps/mobile/android/app/build/outputs/apk/release/app-release.apk
set -euo pipefail 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/**') }}
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: pnpm/action-setup@f40ffcd9367d9f12939873eb1018b921a783ffaa # v4
- uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6 - uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6
with: with:
node-version: 22 node-version: 22
cache: pnpm
- uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # v4.7.1 - uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # v4.7.1
with: with:
distribution: temurin distribution: temurin
java-version: 17 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 - name: Install dependencies
run: pnpm install --frozen-lockfile 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 - name: Install pinned Maestro CLI
if: steps.maestro-cache.outputs.cache-hit != 'true'
run: | run: |
set -euo pipefail set -euo pipefail
if [[ -f "$HOME/.maestro/.installed-2.6.1-3440825f" ]]; then
exit 0
fi
maestro_tmp=$(mktemp -d) maestro_tmp=$(mktemp -d)
trap 'rm -rf "$maestro_tmp"' EXIT trap 'rm -rf "$maestro_tmp"' EXIT
base_url=https://github.com/mobile-dev-inc/maestro/releases/download/cli-2.6.1 base_url=https://github.com/mobile-dev-inc/maestro/releases/download/cli-2.6.1
@ -86,10 +88,8 @@ jobs:
(cd "$maestro_tmp" && sha256sum --check --strict -) (cd "$maestro_tmp" && sha256sum --check --strict -)
(cd "$maestro_tmp" && sha256sum --check --strict checksums_sha256.txt) (cd "$maestro_tmp" && sha256sum --check --strict checksums_sha256.txt)
unzip -q "$maestro_tmp/maestro.zip" -d "$maestro_tmp" unzip -q "$maestro_tmp/maestro.zip" -d "$maestro_tmp"
rm -rf "$HOME/.maestro"
mkdir -p "$HOME/.maestro" mkdir -p "$HOME/.maestro"
cp -R "$maestro_tmp/maestro/." "$HOME/.maestro/" cp -R "$maestro_tmp/maestro/." "$HOME/.maestro/"
touch "$HOME/.maestro/.installed-2.6.1-3440825f"
- name: Configure Maestro CLI - name: Configure Maestro CLI
run: | run: |
@ -112,14 +112,10 @@ jobs:
- name: Save screenshot APK - name: Save screenshot APK
if: steps.screenshot-apk.outputs.cache-hit != 'true' if: steps.screenshot-apk.outputs.cache-hit != 'true'
env: uses: actions/cache/save@0400d5f644dc74513175e3cd8d07132dd4860809 # v4.2.4
APK_CACHE: ${{ steps.screenshot-apk.outputs.path }} with:
run: | path: apps/mobile/android/app/build/outputs/apk/release/app-release.apk
set -euo pipefail key: ${{ steps.screenshot-apk.outputs.cache-primary-key }}
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 - name: Enable KVM
run: | run: |
@ -155,3 +151,12 @@ jobs:
MOBILE_RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} MOBILE_RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
MOBILE_SHA: ${{ github.sha }} MOBILE_SHA: ${{ github.sha }}
run: bash scripts/publish-mobile-screenshot-gallery.sh 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

View File

@ -25,8 +25,8 @@ on:
required: false required: false
type: boolean type: boolean
default: false default: false
hand_off_report: upload_artifacts:
description: Leave the report on the runner host for the trusted publish workflow description: Retain the report and diagnostics as GitHub artifacts
required: false required: false
type: boolean type: boolean
default: false default: false
@ -54,7 +54,7 @@ jobs:
playwright: playwright:
name: Web E2E name: Web E2E
runs-on: [self-hosted, evoo-ci, Linux] runs-on: [self-hosted, evoo-ci, Linux]
timeout-minutes: 60 timeout-minutes: 30
concurrency: concurrency:
group: ${{ inputs.publish_report && 'playwright-publication' || format('playwright-{0}', github.run_id) }} group: ${{ inputs.publish_report && 'playwright-publication' || format('playwright-{0}', github.run_id) }}
cancel-in-progress: false cancel-in-progress: false
@ -83,12 +83,16 @@ jobs:
- uses: actions/setup-node@v4 - uses: actions/setup-node@v4
with: with:
node-version: 22 node-version: 22
cache: pnpm
- run: pnpm install --frozen-lockfile - run: pnpm install --frozen-lockfile
- name: Clear stale Vite dependency cache - name: Clear stale Vite dependency cache
run: rm -rf apps/web/node_modules/.vite node_modules/.vite 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 - name: Install Chromium
timeout-minutes: 5 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, # 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. # and apt-get can stall until the job timeout on persistent self-hosted machines.
run: pnpm --filter @rakazo/web exec playwright install chromium run: pnpm --filter @rakazo/web exec playwright install chromium
@ -145,7 +149,7 @@ jobs:
if: always() && (steps.playwright_tests.outcome == 'success' || steps.playwright_tests.outcome == 'failure') if: always() && (steps.playwright_tests.outcome == 'success' || steps.playwright_tests.outcome == 'failure')
run: pnpm --filter @rakazo/www e2e run: pnpm --filter @rakazo/www e2e
- name: Record Playwright result - name: Record Playwright result
if: always() && inputs.hand_off_report if: always() && inputs.upload_artifacts
env: env:
PLAYWRIGHT_RESULT: ${{ steps.playwright_tests.outcome }} PLAYWRIGHT_RESULT: ${{ steps.playwright_tests.outcome }}
WWW_PLAYWRIGHT_RESULT: ${{ steps.www_playwright_tests.outcome }} WWW_PLAYWRIGHT_RESULT: ${{ steps.www_playwright_tests.outcome }}
@ -158,28 +162,19 @@ jobs:
else else
printf 'failure\n' > test-report/e2e/outcome.txt printf 'failure\n' > test-report/e2e/outcome.txt
fi fi
- name: Hand off Playwright report on the runner host - name: Upload Playwright artifacts
if: always() && inputs.hand_off_report if: always() && inputs.upload_artifacts
continue-on-error: true continue-on-error: true
# Never GitHub artifact storage: the publish workflow runs on the same evo host uses: actions/upload-artifact@v4
# and reads this archive as untrusted input. with:
env: name: playwright-artifacts-${{ github.run_id }}-${{ github.run_attempt }}
HANDOFF_ARCHIVE: ${{ github.run_id }}-${{ github.run_attempt }}.zip path: |
run: | playwright-report
set -euo pipefail playwright-report-www
handoff="$HOME/.cache/evoo-ci/playwright-handoff" apps/web/test-results
mkdir -p "$handoff" test-report/e2e
python3 - "$handoff/$HANDOFF_ARCHIVE.partial" playwright-report playwright-report-www test-report/e2e <<'PY' retention-days: 7
import os, sys, zipfile if-no-files-found: warn
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 - name: Publish Playwright visual report
if: always() && inputs.publish_report if: always() && inputs.publish_report
env: env:

View File

@ -37,17 +37,16 @@ jobs:
- uses: actions/setup-node@v4 - uses: actions/setup-node@v4
with: with:
node-version: 22 node-version: 22
cache: pnpm
- run: pnpm install --frozen-lockfile - run: pnpm install --frozen-lockfile
- name: Read Playwright job result - name: Read Playwright job result
id: playwright id: playwright
env: env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
RUN_ID: ${{ github.event.workflow_run.id }} RUN_ID: ${{ github.event.workflow_run.id }}
RUN_ATTEMPT: ${{ github.event.workflow_run.run_attempt }}
shell: bash shell: bash
run: | run: |
# 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}/jobs?per_page=100" \
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')" --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 case "$result" in
success|failure) ;; success|failure) ;;
@ -76,31 +75,38 @@ jobs:
echo "::warning::Could not resolve a pull request for ${HEAD_OWNER}:${HEAD_BRANCH} at ${HEAD_SHA}." echo "::warning::Could not resolve a pull request for ${HEAD_OWNER}:${HEAD_BRANCH} at ${HEAD_SHA}."
fi fi
echo "number=$number" >> "$GITHUB_OUTPUT" echo "number=$number" >> "$GITHUB_OUTPUT"
- name: Take over Playwright report from the runner host - name: Check for Playwright artifact
id: artifact id: artifact
env: env:
HANDOFF_ARCHIVE: ${{ github.event.workflow_run.id }}-${{ github.event.workflow_run.run_attempt }}.zip ARTIFACT_NAME: playwright-artifacts-${{ github.event.workflow_run.id }}-${{ github.event.workflow_run.run_attempt }}
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
PLAYWRIGHT_JOB_RESULT: ${{ steps.playwright.outputs.job_result }} PLAYWRIGHT_JOB_RESULT: ${{ steps.playwright.outputs.job_result }}
RUN_ID: ${{ github.event.workflow_run.id }}
shell: bash shell: bash
run: | run: |
# The Web E2E job left this archive on the same evo host; GitHub artifact storage is never used. artifacts="$(gh api --method GET \
handoff="$HOME/.cache/evoo-ci/playwright-handoff" "repos/${GITHUB_REPOSITORY}/actions/runs/${RUN_ID}/artifacts?name=${ARTIFACT_NAME}")"
mkdir -p "$handoff" count="$(jq --arg name "$ARTIFACT_NAME" \
find "$handoff" -type f -mtime +1 -delete '[.artifacts[] | select(.name == $name and .expired == false)] | length' \
if [[ -f "$handoff/$HANDOFF_ARCHIVE" ]]; then <<<"$artifacts")"
mv "$handoff/$HANDOFF_ARCHIVE" "$RUNNER_TEMP/playwright-artifacts.zip" if [[ "$count" == "1" ]]; then
echo "exists=true" >> "$GITHUB_OUTPUT" echo "exists=true" >> "$GITHUB_OUTPUT"
elif [[ "$PLAYWRIGHT_JOB_RESULT" == "timed_out" ]]; then echo "id=$(jq -r --arg name "$ARTIFACT_NAME" '.artifacts[] | select(.name == $name and .expired == false) | .id' <<<"$artifacts")" >> "$GITHUB_OUTPUT"
echo "::warning::The timed-out Playwright job ended before it handed off its report. Publishing an empty gallery." elif [[ "$count" == "0" && "$PLAYWRIGHT_JOB_RESULT" == "timed_out" ]]; then
echo "::warning::The timed-out Playwright job ended before it uploaded an artifact. Publishing an empty gallery."
echo "exists=false" >> "$GITHUB_OUTPUT" echo "exists=false" >> "$GITHUB_OUTPUT"
else else
echo "Missing Playwright report $handoff/$HANDOFF_ARCHIVE on this runner host." >&2 echo "Expected exactly one unexpired Playwright artifact; found $count." >&2
exit 1 exit 1
fi fi
- name: Extract Playwright report - name: Download Playwright artifacts
if: steps.artifact.outputs.exists == 'true' if: steps.artifact.outputs.exists == 'true'
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
ARTIFACT_ID: ${{ steps.artifact.outputs.id }}
shell: bash shell: bash
run: | run: |
gh api "repos/${GITHUB_REPOSITORY}/actions/artifacts/${ARTIFACT_ID}/zip" > "$RUNNER_TEMP/playwright-artifacts.zip"
# Keep untrusted files outside the checkout, dependencies, configuration, # Keep untrusted files outside the checkout, dependencies, configuration,
# and generated dashboard consumed by the comment step. # and generated dashboard consumed by the comment step.
python3 scripts/extract-playwright-artifacts.py \ python3 scripts/extract-playwright-artifacts.py \

View File

@ -3,26 +3,9 @@ name: publish-server-image
# Release tags publish the source-addressed image used by in-app updates plus the human-readable # 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 # 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. # 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: on:
push: push:
branches: [main] 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*"] tags: ["v*"]
pull_request: pull_request:
paths: paths:
@ -44,55 +27,12 @@ permissions:
env: env:
# docker/metadata-action otherwise truncates commit tags to seven collision-prone characters. # docker/metadata-action otherwise truncates commit tags to seven collision-prone characters.
DOCKER_METADATA_SHORT_SHA_LENGTH: 40 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: concurrency:
group: publish-server-image-${{ github.event_name == 'pull_request' && format('pr-{0}', github.event.pull_request.number) || github.ref }} group: publish-server-image-${{ github.ref }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }} cancel-in-progress: false
jobs: 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: installer-smoke:
name: Compose installer smokes name: Compose installer smokes
if: github.event_name == 'pull_request' if: github.event_name == 'pull_request'
@ -130,16 +70,7 @@ jobs:
- uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5.1.0 - uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5.1.0
with: with:
persist-credentials: false persist-credentials: false
- name: Prepare isolated Docker config directory - uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0
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 - name: Resolve the lowercase image namespace
id: ns id: ns
# GHCR rejects repository names with capitals; the GitHub repository slug has them. # GHCR rejects repository names with capitals; the GitHub repository slug has them.
@ -149,37 +80,28 @@ jobs:
with: with:
images: ghcr.io/${{ steps.ns.outputs.repo }}/${{ matrix.name }} images: ghcr.io/${{ steps.ns.outputs.repo }}/${{ matrix.name }}
tags: type=sha,prefix=sha- 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 - uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6.19.2
with: with:
builder: ${{ steps.buildx.outputs.name }}
context: ${{ matrix.context }} context: ${{ matrix.context }}
file: ${{ matrix.dockerfile }} file: ${{ matrix.dockerfile }}
push: false push: false
tags: ${{ steps.meta.outputs.tags }} tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }} labels: ${{ steps.meta.outputs.labels }}
build-args: ${{ matrix.name == 'app' && format('GIT_SHA={0}', github.sha) || '' }} build-args: ${{ matrix.name == 'app' && format('GIT_SHA={0}', github.sha) || '' }}
# Pull requests only read the cache main writes. cache-from: type=gha,scope=${{ matrix.name }}
cache-from: type=local,src=${{ steps.cache.outputs.dir }} cache-to: type=gha,mode=max,scope=pr-${{ github.event.pull_request.number }}-${{ matrix.name }}
provenance: false provenance: false
sbom: false sbom: false
build: build:
name: Build ${{ matrix.name }} ${{ matrix.arch }} name: Build ${{ matrix.name }} ${{ matrix.arch }}
needs: changes if: github.event_name != 'pull_request'
if: github.event_name != 'pull_request' && needs.changes.outputs.publish == 'true'
# Each architecture builds natively on its own runner and pushes by digest only. # 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, # 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. # and QEMU emulation took hours for the desktop-sized computer image.
# See docs/self-host.md (Published images and tags). # See docs/self-host.md (Published images and tags).
runs-on: ${{ matrix.runner }} runs-on: ${{ matrix.runner }}
timeout-minutes: 90 timeout-minutes: 90
concurrency:
group: publish-server-image-build-${{ github.ref }}-${{ matrix.name }}-${{ matrix.arch }}
cancel-in-progress: ${{ github.ref == 'refs/heads/main' }}
permissions: permissions:
contents: read contents: read
packages: write packages: write
@ -206,16 +128,7 @@ jobs:
- uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5.1.0 - uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5.1.0
with: with:
persist-credentials: false persist-credentials: false
- name: Prepare isolated Docker config directory - uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0
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 - name: Log in to GHCR
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0 uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0
with: with:
@ -232,14 +145,9 @@ jobs:
# Labels only; the tagged manifest is assembled in the publish job. # Labels only; the tagged manifest is assembled in the publish job.
images: ghcr.io/${{ steps.ns.outputs.repo }}/${{ matrix.name }} images: ghcr.io/${{ steps.ns.outputs.repo }}/${{ matrix.name }}
tags: type=sha,prefix=sha- 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 - id: build
uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6.19.2 uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6.19.2
with: with:
builder: ${{ steps.buildx.outputs.name }}
context: ${{ matrix.context }} context: ${{ matrix.context }}
file: ${{ matrix.dockerfile }} file: ${{ matrix.dockerfile }}
platforms: linux/${{ matrix.arch }} platforms: linux/${{ matrix.arch }}
@ -248,37 +156,33 @@ jobs:
outputs: type=image,name=ghcr.io/${{ steps.ns.outputs.repo }}/${{ matrix.name }},push-by-digest=true,name-canonical=true,push=true 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. # GET /health can report the exact source commit without a deployment-supplied override.
build-args: ${{ matrix.name == 'app' && format('GIT_SHA={0}', github.sha) || '' }} build-args: ${{ matrix.name == 'app' && format('GIT_SHA={0}', github.sha) || '' }}
cache-from: type=local,src=${{ steps.cache.outputs.dir }} cache-from: type=gha,scope=${{ matrix.name }}-${{ matrix.arch }}
cache-to: type=local,mode=max,dest=${{ steps.cache.outputs.dir }}-new cache-to: type=gha,mode=max,scope=${{ matrix.name }}-${{ matrix.arch }}
provenance: mode=max provenance: mode=max
sbom: true sbom: true
- name: Replace the build cache - name: Record the architecture digest
# A local cache only grows; swapping in the fresh export drops layers this build no longer uses. id: digest
env:
CACHE_DIR: ${{ steps.cache.outputs.dir }}
run: |
set -euo pipefail
rm -rf "$CACHE_DIR"
mv "$CACHE_DIR-new" "$CACHE_DIR"
- name: Tag digest for publish job handoff
env: env:
DIGEST: ${{ steps.build.outputs.digest }} DIGEST: ${{ steps.build.outputs.digest }}
IMAGE: ghcr.io/${{ steps.ns.outputs.repo }}/${{ matrix.name }}
run: | run: |
set -euo pipefail set -euo pipefail
docker buildx imagetools create \ digest_dir="${RUNNER_TEMP}/digest"
-t "${IMAGE}:ci-${GITHUB_RUN_ID}-${{ matrix.name }}-${{ matrix.arch }}" \ mkdir -p "$digest_dir"
"${IMAGE}@${DIGEST}" 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
publish: publish:
name: Publish ${{ matrix.name }} image name: Publish ${{ matrix.name }} image
if: github.event_name != 'pull_request' && needs.changes.outputs.publish == 'true' if: github.event_name != 'pull_request'
needs: [changes, build] needs: build
runs-on: [self-hosted, evoo-ci, Linux] runs-on: [self-hosted, evoo-ci, Linux]
timeout-minutes: 15 timeout-minutes: 15
concurrency:
group: publish-server-image-publish-${{ github.ref == 'refs/heads/main' && 'main' || github.ref }}-${{ matrix.name }}
cancel-in-progress: false
permissions: permissions:
contents: read contents: read
packages: write packages: write
@ -292,16 +196,15 @@ jobs:
- name: updater - name: updater
- name: computer - name: computer
steps: steps:
- name: Prepare isolated Docker config directory - name: Prepare an isolated digest directory
run: | id: digest_dir
set -euo pipefail run: echo "path=${RUNNER_TEMP}/digests" >> "$GITHUB_OUTPUT"
cfg="${RUNNER_TEMP}/docker-config" - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4
mkdir -p "$cfg"
echo "DOCKER_CONFIG=${cfg}" >> "$GITHUB_ENV"
- id: buildx
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0
with: with:
name: ci-${{ github.run_id }}-${{ github.run_attempt }}-publish-${{ matrix.name }} pattern: digest-${{ matrix.name }}-*
path: ${{ steps.digest_dir.outputs.path }}
merge-multiple: true
- uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0
- name: Log in to GHCR - name: Log in to GHCR
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0 uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0
with: with:
@ -312,21 +215,6 @@ jobs:
id: ns id: ns
# GHCR rejects repository names with capitals; the GitHub repository slug has them. # GHCR rejects repository names with capitals; the GitHub repository slug has them.
run: echo "repo=${GITHUB_REPOSITORY,,}" >> "$GITHUB_OUTPUT" 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 - id: meta
uses: docker/metadata-action@c299e40c65443455700f0fdfc63efafe5b349051 # v5.10.0 uses: docker/metadata-action@c299e40c65443455700f0fdfc63efafe5b349051 # v5.10.0
with: with:

View File

@ -18,8 +18,6 @@ jobs:
validate: validate:
name: Validate stable release name: Validate stable release
runs-on: [self-hosted, evoo-ci, Linux] runs-on: [self-hosted, evoo-ci, Linux]
permissions:
contents: write
outputs: outputs:
tag: ${{ steps.release.outputs.tag }} tag: ${{ steps.release.outputs.tag }}
version: ${{ steps.release.outputs.version }} version: ${{ steps.release.outputs.version }}
@ -97,32 +95,17 @@ jobs:
set -euo pipefail set -euo pipefail
include='[{"runner":["self-hosted","evoo-ci","macOS"],"artifact":"macos"},{"runner":["self-hosted","evoo-ci","Linux"],"artifact":"linux"}]' include='[{"runner":["self-hosted","evoo-ci","macOS"],"artifact":"macos"},{"runner":["self-hosted","evoo-ci","Linux"],"artifact":"linux"}]'
if [[ "$WINDOWS_SIGNING" == "true" ]]; then if [[ "$WINDOWS_SIGNING" == "true" ]]; then
# Internal runners only: Windows waits for a self-hosted Windows runner, never a GitHub-hosted one. # No self-hosted Windows runner; keep GitHub-hosted only when signing credentials exist.
include="$(jq -c '. + [{"runner":["self-hosted","evoo-ci","Windows"],"artifact":"windows"}]' <<<"$include")" include="$(jq -c '. + [{"runner":"windows-2022","artifact":"windows"}]' <<<"$include")"
fi fi
echo "include=$include" >> "$GITHUB_OUTPUT" echo "include=$include" >> "$GITHUB_OUTPUT"
echo "windows=$WINDOWS_SIGNING" >> "$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: build:
name: Build signed ${{ matrix.artifact }} artifacts name: Build signed ${{ matrix.artifact }} artifacts
needs: validate needs: validate
runs-on: ${{ matrix.runner }} runs-on: ${{ matrix.runner }}
timeout-minutes: 60 timeout-minutes: 60
permissions:
contents: write
strategy: strategy:
fail-fast: false fail-fast: false
matrix: matrix:
@ -162,6 +145,14 @@ jobs:
umask 077 umask 077
printf '%s\n' "$APPLE_API_KEY_P8" > "$APPLE_API_KEY" printf '%s\n' "$APPLE_API_KEY_P8" > "$APPLE_API_KEY"
chmod 600 "$APPLE_API_KEY" chmod 600 "$APPLE_API_KEY"
- name: Build and test the universal Photos helper
if: runner.os == 'macOS'
working-directory: apps/desktop/native/photos-helper
run: |
set -euo pipefail
swift test
swift build -c release --arch arm64 --arch x86_64
lipo -verify_arch .build/apple/Products/Release/photos-helper arm64 x86_64
- name: Package signed and notarized universal macOS app - name: Package signed and notarized universal macOS app
if: runner.os == 'macOS' if: runner.os == 'macOS'
env: env:
@ -189,8 +180,12 @@ jobs:
xcrun stapler validate "$app" xcrun stapler validate "$app"
spctl --assess --type execute --verbose=2 "$app" spctl --assess --type execute --verbose=2 "$app"
grep -Fqx "provider: github" "$app/Contents/Resources/app-update.yml" grep -Fqx "provider: github" "$app/Contents/Resources/app-update.yml"
grep -Fqx "owner: cynarAI" "$app/Contents/Resources/app-update.yml" grep -Fqx "owner: elie222" "$app/Contents/Resources/app-update.yml"
grep -Fqx "repo: evOo-local" "$app/Contents/Resources/app-update.yml" grep -Fqx "repo: rakazo" "$app/Contents/Resources/app-update.yml"
helper="$app/Contents/Resources/bin/photos-helper"
codesign --verify --strict --verbose=2 "$helper"
codesign -d --entitlements - --xml "$helper" | grep -Fq "com.apple.security.personal-information.photos-library"
/usr/libexec/PlistBuddy -c 'Print :NSPhotoLibraryUsageDescription' "$app/Contents/Info.plist"
- name: Require Windows signing credentials - name: Require Windows signing credentials
if: runner.os == 'Windows' if: runner.os == 'Windows'
@ -231,7 +226,7 @@ jobs:
throw "Windows update config is not bound to the signing publisher." throw "Windows update config is not bound to the signing publisher."
} }
$feed = Get-Content -Raw $config $feed = Get-Content -Raw $config
foreach ($expected in @("provider: github", "owner: cynarAI", "repo: evOo-local")) { foreach ($expected in @("provider: github", "owner: elie222", "repo: rakazo")) {
if ($feed -notmatch "(?m)^$([regex]::Escape($expected))\r?$") { if ($feed -notmatch "(?m)^$([regex]::Escape($expected))\r?$") {
throw "Windows update config missing '$expected'." throw "Windows update config missing '$expected'."
} }
@ -256,32 +251,25 @@ jobs:
test -n "$config" test -n "$config"
test -f "$config" test -f "$config"
grep -Fqx "provider: github" "$config" grep -Fqx "provider: github" "$config"
grep -Fqx "owner: cynarAI" "$config" grep -Fqx "owner: elie222" "$config"
grep -Fqx "repo: evOo-local" "$config" grep -Fqx "repo: rakazo" "$config"
test -f apps/desktop/out/latest-linux.yml test -f apps/desktop/out/latest-linux.yml
grep -Fqx "version: $RELEASE_VERSION" apps/desktop/out/latest-linux.yml grep -Fqx "version: $RELEASE_VERSION" apps/desktop/out/latest-linux.yml
- name: Hand installers and updater metadata to the draft release - name: Retain installers and updater metadata
shell: bash uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
env: with:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} name: desktop-${{ matrix.artifact }}-${{ needs.validate.outputs.sha }}
RELEASE_TAG: ${{ needs.validate.outputs.tag }} path: |
run: |
set -euo pipefail
shopt -s nullglob
files=(
apps/desktop/out/*.AppImage apps/desktop/out/*.AppImage
apps/desktop/out/*.blockmap apps/desktop/out/*.blockmap
apps/desktop/out/*.dmg apps/desktop/out/*.dmg
apps/desktop/out/*.exe apps/desktop/out/*.exe
apps/desktop/out/latest*.yml apps/desktop/out/latest*.yml
apps/desktop/out/*.zip apps/desktop/out/*.zip
) if-no-files-found: error
if [[ ${#files[@]} -eq 0 ]]; then compression-level: 0
echo "The build produced no release files." >&2 retention-days: 7
exit 1
fi
bash scripts/upload-release-assets.sh "${files[@]}"
publish: publish:
name: Attest and publish complete release name: Attest and publish complete release
@ -299,11 +287,11 @@ jobs:
fetch-depth: 0 fetch-depth: 0
persist-credentials: false persist-credentials: false
ref: ${{ needs.validate.outputs.sha }} ref: ${{ needs.validate.outputs.sha }}
- name: Collect every platform from the draft release - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4
env: with:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} pattern: desktop-*-${{ needs.validate.outputs.sha }}
RELEASE_TAG: ${{ needs.validate.outputs.tag }} path: release-artifacts
run: gh release download "$RELEASE_TAG" --dir release-artifacts merge-multiple: true
- name: Verify the complete stable update feed - name: Verify the complete stable update feed
env: env:
RELEASE_SHA: ${{ needs.validate.outputs.sha }} RELEASE_SHA: ${{ needs.validate.outputs.sha }}
@ -347,34 +335,26 @@ jobs:
uses: actions/attest-build-provenance@977bb373ede98d70efdf65b84cb5f73e068dcc2a # v3 uses: actions/attest-build-provenance@977bb373ede98d70efdf65b84cb5f73e068dcc2a # v3
with: with:
subject-path: release-artifacts/* subject-path: release-artifacts/*
- name: Add checksums to the draft - name: Create draft and upload every platform
id: create_release
env: env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
RELEASE_TAG: ${{ needs.validate.outputs.tag }} RELEASE_TAG: ${{ needs.validate.outputs.tag }}
run: gh release upload "$RELEASE_TAG" release-artifacts/SHA256SUMS --clobber run: >-
gh release create "$RELEASE_TAG" release-artifacts/*
--draft --generate-notes --title "Rakazo $RELEASE_TAG" --verify-tag
- name: Publish the completed release - name: Publish the completed release
env: env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
RELEASE_TAG: ${{ needs.validate.outputs.tag }} RELEASE_TAG: ${{ needs.validate.outputs.tag }}
run: gh release edit "$RELEASE_TAG" --draft=false --latest run: gh release edit "$RELEASE_TAG" --draft=false --latest
- name: Remove an incomplete draft
discard: if: failure() && steps.create_release.outcome != 'skipped'
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: env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
GH_REPO: ${{ github.repository }}
RELEASE_TAG: ${{ needs.validate.outputs.tag }} RELEASE_TAG: ${{ needs.validate.outputs.tag }}
shell: bash shell: bash
run: | run: |
if [[ "$(gh api "repos/${GITHUB_REPOSITORY}/releases?per_page=100" \ if [[ "$(gh api "repos/${GITHUB_REPOSITORY}/releases/tags/${RELEASE_TAG}" --jq .draft 2>/dev/null)" == "true" ]]; then
--jq "[.[] | select(.draft and .tag_name == \"${RELEASE_TAG}\")] | length")" != "0" ]]; then
gh release delete "$RELEASE_TAG" --yes gh release delete "$RELEASE_TAG" --yes
fi fi

View File

@ -19,6 +19,7 @@ jobs:
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
with: with:
node-version: 22 node-version: 22
cache: pnpm
- run: pnpm install --frozen-lockfile - run: pnpm install --frozen-lockfile
- run: pnpm test:topology - run: pnpm test:topology
- name: Replay computer tools against real Chromium without inference - name: Replay computer tools against real Chromium without inference

View File

@ -11,7 +11,6 @@
- Treat auth, secret handling, sandbox boundaries, host commands, and integrations as security-sensitive. Keep tests deterministic and offline by default. - Treat auth, secret handling, sandbox boundaries, host commands, and integrations as security-sensitive. Keep tests deterministic and offline by default.
- The desktop Playwright suite (`pnpm --filter @rakazo/desktop test:e2e`) opens real Electron windows and steals focus on macOS. Do not run it on a maintainer's machine as routine verification; run the unit tests locally and let CI's virtual-display job run the e2e on push. - The desktop Playwright suite (`pnpm --filter @rakazo/desktop test:e2e`) opens real Electron windows and steals focus on macOS. Do not run it on a maintainer's machine as routine verification; run the unit tests locally and let CI's virtual-display job run the e2e on push.
- Work autonomously. Make routine calls yourself, such as opening or reopening a pull request, linking CI screenshots, or fixing gaps you found that belong to the task, then verify and report the outcome. Ask the maintainer only before irreversible actions or for product decisions without a sensible default. - 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. - After creating a pull request, stay with it until CI and automated review bots have finished; passing checks alone do not mean the review is complete. Follow `.agents/skills/pr-watch/SKILL.md`: run its `pr-digest --watch` helper as a single backgrounded call that blocks until the checks on the current head commit are terminal, then act on the verdict it prints. Do not poll in the foreground on a timer, which mostly buys repeated snapshots of an unchanged PR and dies at the tool timeout. Address every actionable issue, push the fixes, and repeat until no actionable feedback remains. Do not merge while review bots are still pending or review issues remain unresolved.
- PR descriptions say why the change exists, what changed, and how it was tested. Write Why as the product reason (what was wrong and what this changes); never "Elie wants" or a third-person briefing of a named person; the maintainer is the author. - PR descriptions say why the change exists, what changed, and how it was tested. Write Why as the product reason (what was wrong and what this changes); never "Elie wants" or a third-person briefing of a named person; the maintainer is the author.
- Commit messages, PR descriptions, issues, and review replies are public too. Describe test results in words instead of pasting tool output, and never include anything that identifies a person, machine, or account: local paths, usernames, hostnames, signing identities, legal entity names, Apple Team IDs, API key IDs, issuer or tenant IDs, account emails, or the accounts, machines, and files that secret values came from. Naming the secrets a workflow reads is fine; use placeholders when a value must be shown. - Commit messages, PR descriptions, issues, and review replies are public too. Describe test results in words instead of pasting tool output, and never include anything that identifies a person, machine, or account: local paths, usernames, hostnames, signing identities, legal entity names, Apple Team IDs, API key IDs, issuer or tenant IDs, account emails, or the accounts, machines, and files that secret values came from. Naming the secrets a workflow reads is fine; use placeholders when a value must be shown.

View File

@ -1,114 +0,0 @@
# 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.

View File

@ -49,7 +49,7 @@ Full local notes: **[docs/LOCAL-SETUP.md](./docs/LOCAL-SETUP.md)**
- TypeScript monorepo (pnpm + Turborepo) - TypeScript monorepo (pnpm + Turborepo)
- React 19 / Vite / Tailwind (web), Electron (desktop), Expo (mobile) - React 19 / Vite / Tailwind (web), Electron (desktop), Expo (mobile)
- Hono + oRPC, PostgreSQL + Prisma, Better Auth, Graphile Worker - Hono + oRPC, PostgreSQL + Prisma, Better Auth, Graphile Worker
- Pi model routing, Docker sandboxes, Voice (Kokoro, local) - Pi model routing, Docker sandboxes, Voice (Kokoro local / BYO keys)
## Antigravity tools kept on this branch ## 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 export KOKORO_BASE_URL=http://127.0.0.1:8880
``` ```
German voices come from a local Piper server (`PIPER_BASE_URL`). Details: [docs/kokoro-voice.md](./docs/kokoro-voice.md) Details: [docs/kokoro-voice.md](./docs/kokoro-voice.md)
## Tech debt (brand / packages) ## Tech debt (brand / packages)

View File

@ -1,135 +0,0 @@
# 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
```

View File

@ -1,123 +0,0 @@
# 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
```

View File

@ -60,8 +60,6 @@ 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. 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. 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. 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 ## Scope

View File

@ -19,6 +19,7 @@
"@rakazo/core": "workspace:*", "@rakazo/core": "workspace:*",
"@rakazo/db": "workspace:*", "@rakazo/db": "workspace:*",
"@rakazo/logging": "workspace:*", "@rakazo/logging": "workspace:*",
"@rakazo/media": "workspace:*",
"@rakazo/memory": "workspace:*", "@rakazo/memory": "workspace:*",
"hono": "4.13.7" "hono": "4.13.7"
}, },

View File

@ -26,13 +26,9 @@ function savedSkill(name: string, source = "user") {
} }
function setup(rows: ReturnType<typeof savedSkill>[] = []) { function setup(rows: ReturnType<typeof savedSkill>[] = []) {
type Where = { id?: string; spaceId?: string; userId: string; source?: string; name?: unknown }; type Where = { id?: string; spaceId: string; userId: string; source?: string };
const matches = (row: ReturnType<typeof savedSkill>, where: Where) => const matches = (row: ReturnType<typeof savedSkill>, where: Where) =>
Object.entries(where).every(([key, value]) => Object.entries(where).every(([key, value]) => row[key as keyof typeof row] === 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 = { const agentSkill = {
findMany: vi.fn(async ({ where }: { where: Where }) => findMany: vi.fn(async ({ where }: { where: Where }) =>
rows.filter((row) => matches(row, where)), rows.filter((row) => matches(row, where)),
@ -59,19 +55,15 @@ 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", () => { describe("built-in skill precedence in the API", () => {
it.each(["interrogate", " Interrogate "])( it.each(["interrogate", " Interrogate "])(
"keeps %j listed, readable and mutable", "keeps %j listed, readable and mutable",
async (name) => { async (name) => {
const { service } = setup([savedSkill(name)]); const { service } = setup([savedSkill(name)]);
expect(interrogate(await service.list(actor))).toEqual([ await expect(service.list(actor)).resolves.toEqual([
expect.objectContaining({ id: "saved-1", name, readOnly: false }), expect.objectContaining({ id: "saved-1", name, readOnly: false }),
]); ]);
expect(interrogate(await service.listWithContent(actor))).toEqual([ await expect(service.listWithContent(actor)).resolves.toEqual([
expect.objectContaining({ id: "saved-1", content: expect.stringContaining("Saved steps") }), expect.objectContaining({ id: "saved-1", content: expect.stringContaining("Saved steps") }),
]); ]);
await expect(service.get(actor, { name: " INTERROGATE " })).resolves.toMatchObject({ await expect(service.get(actor, { name: " INTERROGATE " })).resolves.toMatchObject({
@ -105,23 +97,7 @@ describe("built-in skill precedence in the API", () => {
expect(agentSkill.deleteMany).not.toHaveBeenCalled(); expect(agentSkill.deleteMany).not.toHaveBeenCalled();
}); });
it("lists the user's skills from other spaces too, the current space winning a clash", async () => { it.each([{ spaceId: "other-space" }, { userId: "other-user" }])(
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", "does not let foreign skills shadow the builtin: %j",
async (foreignOwner) => { async (foreignOwner) => {
const { service } = setup([{ ...savedSkill(" Interrogate "), ...foreignOwner }]); const { service } = setup([{ ...savedSkill(" Interrogate "), ...foreignOwner }]);
@ -129,7 +105,7 @@ describe("built-in skill precedence in the API", () => {
id: "builtin:Interrogate", id: "builtin:Interrogate",
readOnly: true, readOnly: true,
}); });
expect(interrogate(await service.list(actor))).toEqual([ await expect(service.list(actor)).resolves.toEqual([
expect.objectContaining({ id: "builtin:Interrogate" }), expect.objectContaining({ id: "builtin:Interrogate" }),
]); ]);
await expect(service.get(actor, { skillId: "saved-1" })).rejects.toThrow(); await expect(service.get(actor, { skillId: "saved-1" })).rejects.toThrow();

View File

@ -1,5 +1,5 @@
import { ORPCError } from "@orpc/server"; import { ORPCError } from "@orpc/server";
import { BUILTIN_AGENT_SKILLS } from "@rakazo/adapters"; import { BUILTIN_AGENT_SKILLS, filterMediaSkills } from "@rakazo/adapters";
import type { Actor, AgentSkill, AgentSkillSource } from "@rakazo/contracts"; import type { Actor, AgentSkill, AgentSkillSource } from "@rakazo/contracts";
import { import {
buildSkillMd, buildSkillMd,
@ -7,7 +7,6 @@ import {
isSkillReadOnly, isSkillReadOnly,
mergeBuiltinSkills, mergeBuiltinSkills,
parseSkillMd, parseSkillMd,
preferSpaceSkills,
type SkillSource, type SkillSource,
} from "@rakazo/core"; } from "@rakazo/core";
import { IsolationError, type PrismaClient } from "@rakazo/db"; import { IsolationError, type PrismaClient } from "@rakazo/db";
@ -119,10 +118,20 @@ export function resolveSkillContent(input: {
}; };
} }
export function createAgentSkillsService(prisma: PrismaClient) { export function createAgentSkillsService(
prisma: PrismaClient,
options: { mediaConfigured?: boolean } = {},
) {
/** Built-in skills this deployment can run: media recipes only with Media Studio. */
const visibleBuiltins = () =>
filterMediaSkills(builtinCatalog(), Boolean(options.mediaConfigured));
async function owned(actor: Actor, skillId: string) { async function owned(actor: Actor, skillId: string) {
const row = await prisma.agentSkill.findFirst({ const row = await prisma.agentSkill.findFirst({
where: { id: skillId, userId: actor.userId }, where: {
id: skillId,
spaceId: actor.spaceId,
userId: actor.userId,
},
}); });
if (!row) throw new IsolationError(); if (!row) throw new IsolationError();
return row; return row;
@ -131,29 +140,25 @@ export function createAgentSkillsService(prisma: PrismaClient) {
return { return {
async list(actor: Actor): Promise<Omit<AgentSkill, "content">[]> { async list(actor: Actor): Promise<Omit<AgentSkill, "content">[]> {
const rows = await prisma.agentSkill.findMany({ const rows = await prisma.agentSkill.findMany({
where: { userId: actor.userId }, where: { spaceId: actor.spaceId, userId: actor.userId },
orderBy: [{ name: "asc" }, { id: "asc" }], orderBy: [{ name: "asc" }, { id: "asc" }],
}); });
return mergeBuiltinSkills( return mergeBuiltinSkills(visibleBuiltins(), rows.map(mapAgentSkill)).map(
builtinCatalog(), ({ content: _content, ...entry }) => entry,
preferSpaceSkills(rows, actor.spaceId).map(mapAgentSkill), );
).map(({ content: _content, ...entry }) => entry);
}, },
async listWithContent(actor: Actor): Promise<AgentSkill[]> { async listWithContent(actor: Actor): Promise<AgentSkill[]> {
const rows = await prisma.agentSkill.findMany({ const rows = await prisma.agentSkill.findMany({
where: { userId: actor.userId }, where: { spaceId: actor.spaceId, userId: actor.userId },
orderBy: [{ name: "asc" }, { id: "asc" }], orderBy: [{ name: "asc" }, { id: "asc" }],
}); });
return mergeBuiltinSkills( return mergeBuiltinSkills(visibleBuiltins(), rows.map(mapAgentSkill));
builtinCatalog(),
preferSpaceSkills(rows, actor.spaceId).map(mapAgentSkill),
);
}, },
async get(actor: Actor, input: { skillId?: string; name?: string }): Promise<AgentSkill> { async get(actor: Actor, input: { skillId?: string; name?: string }): Promise<AgentSkill> {
if (input.skillId?.startsWith("builtin:")) { if (input.skillId?.startsWith("builtin:")) {
const builtin = builtinCatalog().find((skill) => skill.id === input.skillId); const builtin = visibleBuiltins().find((skill) => skill.id === input.skillId);
if (!builtin) throw new IsolationError(); if (!builtin) throw new IsolationError();
return builtin; return builtin;
} }
@ -162,12 +167,15 @@ export function createAgentSkillsService(prisma: PrismaClient) {
} }
const name = input.name?.trim() ?? ""; const name = input.name?.trim() ?? "";
const rows = await prisma.agentSkill.findMany({ const rows = await prisma.agentSkill.findMany({
where: { userId: actor.userId }, where: {
spaceId: actor.spaceId,
userId: actor.userId,
},
orderBy: [{ name: "asc" }, { id: "asc" }], orderBy: [{ name: "asc" }, { id: "asc" }],
}); });
const row = findSkillByName(preferSpaceSkills(rows, actor.spaceId), name); const row = findSkillByName(rows, name);
if (row) return mapAgentSkill(row); if (row) return mapAgentSkill(row);
const builtin = findSkillByName(builtinCatalog(), name); const builtin = findSkillByName(visibleBuiltins(), name);
if (!builtin) throw new IsolationError(); if (!builtin) throw new IsolationError();
return builtin; return builtin;
}, },
@ -179,6 +187,7 @@ export function createAgentSkillsService(prisma: PrismaClient) {
const resolved = resolveSkillContent(input); const resolved = resolveSkillContent(input);
const clash = await prisma.agentSkill.findFirst({ const clash = await prisma.agentSkill.findFirst({
where: { where: {
spaceId: actor.spaceId,
userId: actor.userId, userId: actor.userId,
name: { equals: resolved.name, mode: "insensitive" }, name: { equals: resolved.name, mode: "insensitive" },
}, },
@ -231,6 +240,7 @@ export function createAgentSkillsService(prisma: PrismaClient) {
if (!findSkillByName([existing], resolved.name)) { if (!findSkillByName([existing], resolved.name)) {
const clash = await prisma.agentSkill.findFirst({ const clash = await prisma.agentSkill.findFirst({
where: { where: {
spaceId: actor.spaceId,
userId: actor.userId, userId: actor.userId,
name: { equals: resolved.name, mode: "insensitive" }, name: { equals: resolved.name, mode: "insensitive" },
NOT: { id: existing.id }, NOT: { id: existing.id },
@ -246,7 +256,12 @@ export function createAgentSkillsService(prisma: PrismaClient) {
// Mutate only owner-scoped user rows (never builtin/plugin), even if source was tampered. // Mutate only owner-scoped user rows (never builtin/plugin), even if source was tampered.
try { try {
const updated = await prisma.agentSkill.updateMany({ const updated = await prisma.agentSkill.updateMany({
where: { id: existing.id, userId: actor.userId, source: "user" }, where: {
id: existing.id,
spaceId: actor.spaceId,
userId: actor.userId,
source: "user",
},
data: { data: {
name: resolved.name, name: resolved.name,
description: resolved.description, description: resolved.description,
@ -266,7 +281,11 @@ export function createAgentSkillsService(prisma: PrismaClient) {
throw error; throw error;
} }
const row = await prisma.agentSkill.findFirst({ const row = await prisma.agentSkill.findFirst({
where: { id: existing.id, userId: actor.userId }, where: {
id: existing.id,
spaceId: actor.spaceId,
userId: actor.userId,
},
}); });
if (!row) throw new IsolationError(); if (!row) throw new IsolationError();
return mapAgentSkill(row); return mapAgentSkill(row);
@ -278,7 +297,12 @@ export function createAgentSkillsService(prisma: PrismaClient) {
throw new ORPCError("BAD_REQUEST", { message: "Builtin and plugin skills are read-only." }); throw new ORPCError("BAD_REQUEST", { message: "Builtin and plugin skills are read-only." });
} }
const deleted = await prisma.agentSkill.deleteMany({ const deleted = await prisma.agentSkill.deleteMany({
where: { id: existing.id, userId: actor.userId, source: "user" }, where: {
id: existing.id,
spaceId: actor.spaceId,
userId: actor.userId,
source: "user",
},
}); });
if (deleted.count !== 1) throw new IsolationError(); if (deleted.count !== 1) throw new IsolationError();
return { ok: true }; return { ok: true };

View File

@ -20,8 +20,8 @@ function setup() {
secret: { findMany: vi.fn(async () => []) }, secret: { findMany: vi.fn(async () => []) },
bot: { findMany: vi.fn(async () => []) }, bot: { findMany: vi.fn(async () => []) },
spaceVoicePreference: { spaceVoicePreference: {
findFirst: vi.fn(async () => ({ credential: { provider: "kokoro" }, voiceId: "af_heart" })), findFirst: vi.fn(async () => ({ credential: { provider: "openai" }, voiceId: "alloy" })),
findMany: vi.fn(async () => [{ credential: { provider: "kokoro" } }]), findMany: vi.fn(async () => [{ credential: { provider: "openai" } }]),
}, },
spaceMemoryConfig: { spaceMemoryConfig: {
findUnique: vi.fn(async () => null), findUnique: vi.fn(async () => null),
@ -29,7 +29,6 @@ function setup() {
}, },
spaceMember: { findUnique: vi.fn(async () => ({ role: "owner" })) }, spaceMember: { findUnique: vi.fn(async () => ({ role: "owner" })) },
deploymentSettings: { findUnique: vi.fn(async () => null) }, deploymentSettings: { findUnique: vi.fn(async () => null) },
integrationProviderConfig: { findUnique: vi.fn(async () => null) },
aiDataConsent: { findMany: vi.fn(async () => []), upsert }, aiDataConsent: { findMany: vi.fn(async () => []), upsert },
$transaction: vi.fn(async (queries) => Promise.all(queries)), $transaction: vi.fn(async (queries) => Promise.all(queries)),
}, },
@ -104,12 +103,12 @@ describe("consent grants", () => {
it("discloses only the selected voice provider before a voice action", async () => { it("discloses only the selected voice provider before a voice action", async () => {
const { deps, actor } = setup(); const { deps, actor } = setup();
vi.mocked(deps.prisma.spaceVoicePreference.findMany).mockResolvedValue([ vi.mocked(deps.prisma.spaceVoicePreference.findMany).mockResolvedValue([
{ credential: { provider: "kokoro" } }, { credential: { provider: "openai" } },
{ credential: { provider: "scripted" } }, { credential: { provider: "elevenlabs" } },
] as never); ] as never);
const status = await aiConsentStatus(deps, actor, { uses: ["voice"] }); const status = await aiConsentStatus(deps, actor, { uses: ["voice"] });
expect(status.recipients).toHaveLength(1); expect(status.recipients).toHaveLength(1);
expect(status.recipients[0]?.name).toBe("Local voice"); expect(status.recipients[0]?.name).toBe("OpenAI");
expect(deps.prisma.spaceVoicePreference.findMany).not.toHaveBeenCalled(); expect(deps.prisma.spaceVoicePreference.findMany).not.toHaveBeenCalled();
}); });
it("discloses memory provider details to space owners", async () => { it("discloses memory provider details to space owners", async () => {
@ -135,54 +134,6 @@ describe("consent grants", () => {
expect(JSON.stringify(status.recipients)).not.toContain("supermemory"); expect(JSON.stringify(status.recipients)).not.toContain("supermemory");
expect(deps.prisma.spaceMemoryConfig.findUnique).not.toHaveBeenCalled(); expect(deps.prisma.spaceMemoryConfig.findUnique).not.toHaveBeenCalled();
}); });
it("discloses the deployment's media servers before model use, keyed by server", async () => {
const { deps, actor } = setup();
const settings = {
comfyui: { baseUrl: "http://127.0.0.1:8188", apiKey: "comfy-key", imageModel: "sdxl" },
transcription: { baseUrl: "http://127.0.0.1:8080/v1", model: "whisper-1" },
};
vi.mocked(deps.prisma.integrationProviderConfig.findUnique).mockResolvedValue({
id: "media",
ciphertext: "fake-media-ciphertext",
} as never);
deps.secrets = {
load: vi.fn(() => JSON.stringify(settings)),
} as unknown as RouterDeps["secrets"];
const status = await aiConsentStatus(deps, actor, { botId: "bot", uses: ["model"] });
expect(status.recipients.map((recipient) => recipient.name)).toEqual([
"ComfyUI (http://127.0.0.1:8188)",
"Transcription (http://127.0.0.1:8080)",
]);
expect(status.recipients[0]?.detail).toBe("Model: sdxl.");
expect(JSON.stringify(status)).not.toContain("comfy-key");
// 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();
vi.mocked(deps.prisma.integrationProviderConfig.findUnique).mockResolvedValue({
id: "media",
ciphertext: "fake-media-ciphertext-voice",
} as never);
deps.secrets = {
load: vi.fn(() =>
JSON.stringify({ comfyui: null, transcription: { baseUrl: "http://127.0.0.1:8080/v1" } }),
),
} as unknown as RouterDeps["secrets"];
vi.mocked(deps.prisma.spaceVoicePreference.findFirst).mockResolvedValue({
credential: { provider: "kokoro" },
voiceId: "af_heart",
} as never);
const status = await aiConsentStatus(deps, actor, { uses: ["voice"] });
expect(status.recipients.map((recipient) => [recipient.name, recipient.use])).toEqual([
["Local voice", "voice"],
["Transcription (http://127.0.0.1:8080)", "voice"],
]);
});
it("returns an operator policy without requiring a hosted provider", async () => { it("returns an operator policy without requiring a hosted provider", async () => {
const { deps, actor } = setup(); const { deps, actor } = setup();
deps.env.privacyPolicyUrl = "https://example.com/privacy"; deps.env.privacyPolicyUrl = "https://example.com/privacy";

View File

@ -3,13 +3,9 @@ import { ORPCError } from "@orpc/server";
import { import {
aiRecipient, aiRecipient,
cloudAgentsEnabled, cloudAgentsEnabled,
loadMediaSettings,
MEDIA_COMFYUI_PROVIDER_ID,
MEDIA_TRANSCRIPTION_PROVIDER_ID,
parseModelSecret, parseModelSecret,
selectConfiguredModel, selectConfiguredModel,
toStringRecord, toStringRecord,
voiceCatalogEntry,
} from "@rakazo/adapters"; } from "@rakazo/adapters";
import type { Actor, AiConsentQuery, AiConsentStatus, AiRecipient } from "@rakazo/contracts"; import type { Actor, AiConsentQuery, AiConsentStatus, AiRecipient } from "@rakazo/contracts";
import { AI_DATA_DISCLOSURES, AI_DISCLOSURE_VERSION, AI_PRIVACY_URL } from "@rakazo/contracts"; import { AI_DATA_DISCLOSURES, AI_DISCLOSURE_VERSION, AI_PRIVACY_URL } from "@rakazo/contracts";
@ -187,21 +183,6 @@ export async function aiConsentStatus(
} }
for (const voice of voices) for (const voice of voices)
add(aiRecipient({ provider: voice.credential.provider, use: "voice" })); add(aiRecipient({ provider: voice.credential.provider, use: "voice" }));
if (uses.includes("voice")) {
// Dictation goes to the owner's transcription server when the voice provider cannot transcribe.
const transcription = (await loadMediaSettings(deps)).transcription;
const credential = transcription ? await findDefaultVoiceCredential(deps.prisma, actor) : null;
if (transcription && !(credential && voiceCatalogEntry(credential.provider)?.transcribe)) {
add(
aiRecipient({
provider: MEDIA_TRANSCRIPTION_PROVIDER_ID,
use: "voice",
baseUrl: transcription.baseUrl,
modelId: transcription.model,
}),
);
}
}
if (includesMemory) { if (includesMemory) {
if (memoryOwner) { if (memoryOwner) {
const memory = await deps.prisma.spaceMemoryConfig.findUnique({ const memory = await deps.prisma.spaceMemoryConfig.findUnique({
@ -229,30 +210,6 @@ export async function aiConsentStatus(
) { ) {
add(aiRecipient({ provider: deps.cloudAgent.provider.describe().id, use: "model" })); add(aiRecipient({ provider: deps.cloudAgent.provider.describe().id, use: "model" }));
} }
if (uses.includes("model")) {
// Media tools and card actions send prompts, images, video and audio to the owner's servers.
const media = await loadMediaSettings(deps);
if (media.comfyui) {
add(
aiRecipient({
provider: MEDIA_COMFYUI_PROVIDER_ID,
use: "model",
baseUrl: media.comfyui.baseUrl,
modelId: media.comfyui.imageModel,
}),
);
}
if (media.transcription) {
add(
aiRecipient({
provider: MEDIA_TRANSCRIPTION_PROVIDER_ID,
use: "model",
baseUrl: media.transcription.baseUrl,
modelId: media.transcription.model,
}),
);
}
}
return { return {
scope: createHash("sha256") scope: createHash("sha256")
.update(JSON.stringify([actor.userId, actor.spaceId])) .update(JSON.stringify([actor.userId, actor.spaceId]))

View File

@ -1,5 +1,6 @@
import { randomUUID } from "node:crypto"; import { randomUUID } from "node:crypto";
import { ORPCError, onError, ValidationError } from "@orpc/server"; import { rm } from "node:fs/promises";
import { ORPCError, onError } from "@orpc/server";
import { RPCHandler } from "@orpc/server/fetch"; import { RPCHandler } from "@orpc/server/fetch";
import type { import type {
AgentRuntime, AgentRuntime,
@ -14,7 +15,6 @@ import type {
ComposioProvider, ComposioProvider,
ConnectorRegistry, ConnectorRegistry,
DestinationEmulator, DestinationEmulator,
MediaOperationRegistration,
RemoteConnectorDependencies, RemoteConnectorDependencies,
} from "@rakazo/adapters"; } from "@rakazo/adapters";
import { import {
@ -25,34 +25,30 @@ import {
createCloudAgentConnection, createCloudAgentConnection,
createConnectorStack, createConnectorStack,
createJobReconciler, createJobReconciler,
createMediaOperations, createMediaJobHandlers,
createMediaRuntime, createMediaServices,
createMessagingContextLoader, createMessagingContextLoader,
createMessagingTeamChatSender, createMessagingTeamChatSender,
createPostgresReconciliationLeadership,
createProfileConsolidationScheduler, createProfileConsolidationScheduler,
createRunExecutor, createRunExecutor,
createRunSandbox, createRunSandbox,
createRunSecretWriter, createRunSecretWriter,
createWebProvider, createWebProvider,
destroyBot, destroyBot,
destroySpaceSandboxes,
EmailEmulator, EmailEmulator,
EncryptedSecretStore, EncryptedSecretStore,
ExpoPushSender, ExpoPushProvider,
GraphileJobPublisher, GraphileJobPublisher,
GraphileJobWorkerHost, GraphileJobWorkerHost,
InMemoryJobQueue, InMemoryJobQueue,
InMemoryRealtimeFanout, InMemoryRealtimeFanout,
InstalledConnectorProvider, InstalledConnectorProvider,
IntegrationProviderSettings, IntegrationProviderSettings,
importLegacyPushTokens,
isComposioEnabled, isComposioEnabled,
isMessagingSurfaceEnabled, isMessagingSurfaceEnabled,
isPipedreamEnabled, isPipedreamEnabled,
LocalAgentHomeStore, LocalAgentHomeStore,
LocalArtifactStore, LocalArtifactStore,
loadMediaSettings,
McpConnector, McpConnector,
McpOAuthBroker, McpOAuthBroker,
messagingPlatformsFromEnv, messagingPlatformsFromEnv,
@ -60,9 +56,9 @@ import {
PiOAuthLogins, PiOAuthLogins,
PipedreamConnector, PipedreamConnector,
PostgresRealtimeFanout, PostgresRealtimeFanout,
PushNotificationProvider,
pipedreamConfigFromEnv, pipedreamConfigFromEnv,
piSessionsRoot, piSessionsRoot,
pushTokenPath,
reconcileCloudAgents, reconcileCloudAgents,
reconcileComputerUpdates, reconcileComputerUpdates,
reconcileDelegatedTasks, reconcileDelegatedTasks,
@ -71,9 +67,6 @@ import {
SmtpEmailProvider, SmtpEmailProvider,
SpaceMemoryProviderResolver, SpaceMemoryProviderResolver,
toTeamChatInbound, toTeamChatInbound,
VapidKeys,
vapidEnvFromProcess,
WebPushSender,
} from "@rakazo/adapters"; } from "@rakazo/adapters";
import { blockedAuthPaths, createAuth } from "@rakazo/auth"; import { blockedAuthPaths, createAuth } from "@rakazo/auth";
import { signupPolicyFromEnv } from "@rakazo/core"; import { signupPolicyFromEnv } from "@rakazo/core";
@ -85,7 +78,6 @@ import {
parsePositiveInteger, parsePositiveInteger,
provisionMessagingIdentity, provisionMessagingIdentity,
requireMembership, requireMembership,
spaceIdsForSandboxTeardownAfterAccountDelete,
} from "@rakazo/db"; } from "@rakazo/db";
import type { Logger } from "@rakazo/logging"; import type { Logger } from "@rakazo/logging";
import { import {
@ -99,10 +91,10 @@ import { requestLogging } from "@rakazo/logging/hono";
import { MarkdownMemoryStore } from "@rakazo/memory"; import { MarkdownMemoryStore } from "@rakazo/memory";
import { Hono } from "hono"; import { Hono } from "hono";
import { cors } from "hono/cors"; import { cors } from "hono/cors";
import { mountArtifactHttpRoutes } from "./artifact-http.js";
import type { AppEnv } from "./env.js"; import type { AppEnv } from "./env.js";
import { loadEnv } from "./env.js"; import { loadEnv } from "./env.js";
import { mountLocalSettings } from "./local-settings.js"; import { mountLocalSettings } from "./local-settings.js";
import { mountMediaHttpRoutes } from "./media-http.js";
import { import {
createMessagingInboundHandler, createMessagingInboundHandler,
teamChatSenderCanWakeMessageRoutines, teamChatSenderCanWakeMessageRoutines,
@ -121,7 +113,6 @@ import {
TEAM_CHAT_STARTUP_SHUTDOWN_MS, TEAM_CHAT_STARTUP_SHUTDOWN_MS,
} from "./team-chat-startup.js"; } from "./team-chat-startup.js";
import { mountVoiceHttpRoutes } from "./voice.js"; import { mountVoiceHttpRoutes } from "./voice.js";
import { isLoopbackHost, isPrivateOrLocalHost, requestWebOrigin } from "./web-origin.js";
import { mountWebhookHttpRoutes } from "./webhook.js"; import { mountWebhookHttpRoutes } from "./webhook.js";
export interface AppHandles { export interface AppHandles {
@ -150,8 +141,6 @@ export async function createApp(
email?: TransactionalEmailProvider; email?: TransactionalEmailProvider;
remoteConnectors?: RemoteConnectorDependencies; remoteConnectors?: RemoteConnectorDependencies;
logger?: Logger; logger?: Logger;
/** Test seam: media operations instead of the configured ones. */
mediaOperations?: MediaOperationRegistration[];
} = {}, } = {},
): Promise<AppHandles> { ): Promise<AppHandles> {
const { const {
@ -164,7 +153,6 @@ export async function createApp(
email: emailOverride, email: emailOverride,
remoteConnectors, remoteConnectors,
logger: loggerOverride, logger: loggerOverride,
mediaOperations,
...envOverrides ...envOverrides
} = overrides; } = overrides;
const env = { ...loadEnv(process.env), ...envOverrides }; const env = { ...loadEnv(process.env), ...envOverrides };
@ -323,14 +311,7 @@ export async function createApp(
: new PiAgentRuntime({ : new PiAgentRuntime({
sessionRoot: env.piSessionRecording ? piSessionsRoot(env.dataDir) : undefined, sessionRoot: env.piSessionRecording ? piSessionsRoot(env.dataDir) : undefined,
}); });
const vapid = new VapidKeys(prisma, secrets, vapidEnvFromProcess(process.env)); const notifications = new ExpoPushProvider(env.dataDir);
const notifications = new PushNotificationProvider(prisma, {
webpush: new WebPushSender(vapid),
expo: new ExpoPushSender(),
});
void importLegacyPushTokens(prisma, env.dataDir).catch((error) =>
getLogger().error("legacy push token import failed", error),
);
const auth = createAuth(prisma, { const auth = createAuth(prisma, {
secret: env.authSecret, secret: env.authSecret,
baseURL: env.authUrl, baseURL: env.authUrl,
@ -361,46 +342,32 @@ export async function createApp(
"https://172.*:*", "https://172.*:*",
"http://*.local:*", "http://*.local:*",
"https://*.local:*", "https://*.local:*",
...(env.extraTrustedOrigins ?? []), ...(process.env.EXTRA_TRUSTED_ORIGINS?.split(",").map((s) => s.trim()) ?? []),
], ],
beforeDeleteUser: async (userId) => { beforeDeleteUser: async (userId) => {
const bots = await prisma.bot.findMany({ const bots = await prisma.bot.findMany({
where: { userId }, where: { userId },
select: { id: true, userId: true, spaceId: true, name: true, archivedAt: true }, 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( await Promise.all(
bots.map((bot) => bots.map((bot) =>
destroyBot( destroyBot(
lifecycleDeps, { prisma, sandbox, home, jobs, artifacts, dataDir: env.dataDir },
bot, bot,
{ ...deleteContext, spaceId: bot.spaceId, botId: bot.id }, {
operationId: `account-delete:${userId}`,
traceId: `account-delete:${userId}`,
spaceId: bot.spaceId,
userId,
botId: bot.id,
signal: new AbortController().signal,
},
{ deleteMemories: true }, { 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); await removePiUserSessions(env.dataDir, userId);
await rm(pushTokenPath(env.dataDir, userId), { force: true }).catch(() => undefined);
}, },
}); });
// One provider instance so emulator launches and polls share the same Map. // One provider instance so emulator launches and polls share the same Map.
@ -410,15 +377,6 @@ export async function createApp(
CLOUD_AGENT_SPACE_ID: env.cloudAgentSpaceId, CLOUD_AGENT_SPACE_ID: env.cloudAgentSpaceId,
}); });
const shutdown = new AbortController(); const shutdown = new AbortController();
const media = createMediaRuntime({
prisma,
events,
jobs,
artifacts,
registrations:
mediaOperations ??
createMediaOperations({ loadSettings: () => loadMediaSettings({ prisma, secrets }) }),
});
const executor = createRunExecutor({ const executor = createRunExecutor({
prisma, prisma,
runtime, runtime,
@ -455,10 +413,23 @@ export async function createApp(
messaging: messaging ? createMessagingContextLoader(prisma) : undefined, messaging: messaging ? createMessagingContextLoader(prisma) : undefined,
web: createWebProvider(), web: createWebProvider(),
cloudAgent, cloudAgent,
media,
shutdownSignal: shutdown.signal, shutdownSignal: shutdown.signal,
// Late-bound: media services are built below and need executor.resolveModel.
media: () => mediaServices,
}); });
const mediaServices = createMediaServices({
config: env.media,
prisma,
jobs,
artifacts,
runtime,
realtime,
resolveModel: (scope) => executor.resolveModel(scope),
});
const mediaJobs = mediaServices
? createMediaJobHandlers(mediaServices, { signal: shutdown.signal })
: null;
const jobHandlers = createBackgroundJobHandlers({ const jobHandlers = createBackgroundJobHandlers({
executor, executor,
prisma, prisma,
@ -466,6 +437,7 @@ export async function createApp(
home, home,
jobs, jobs,
events, events,
...(mediaJobs ? { media: mediaJobs.handlers } : {}),
workerId: "api", workerId: "api",
runtime, runtime,
secretStore: secrets, secretStore: secrets,
@ -473,37 +445,35 @@ export async function createApp(
deploymentModelKey: env.deploymentModelKey, deploymentModelKey: env.deploymentModelKey,
messaging, messaging,
cloudAgent, cloudAgent,
media,
}); });
let graphileWorkerHost: GraphileJobWorkerHost | undefined;
if (inMemoryJobs) { if (inMemoryJobs) {
await inMemoryJobs.start(jobHandlers); await inMemoryJobs.start(jobHandlers);
} else if (jobPool) { } else if (jobPool) {
graphileWorkerHost = new GraphileJobWorkerHost(jobPool, { const graphileWorkerHost = new GraphileJobWorkerHost(jobPool, {
concurrency: parsePositiveInteger(process.env.GRAPHILE_WORKER_CONCURRENCY, 4), concurrency: 4,
pollInterval: 500, pollInterval: 500,
}); });
await graphileWorkerHost.start(jobHandlers); await graphileWorkerHost.start(jobHandlers);
} }
const reconciler = if (mediaJobs) {
inMemoryJobs || !prismaOverride void mediaJobs
? createJobReconciler({ .resume()
prisma, .catch((error) => getLogger().error("media ingest resume failed", error));
jobs, }
events, const reconciler = inMemoryJobs
leadership: jobPool ? createPostgresReconciliationLeadership(jobPool) : undefined, ? createJobReconciler({
reconcileCloudAgents: () => reconcileCloudAgents({ prisma, jobs, cloudAgent }), prisma,
reconcileComputerUpdates: () => reconcileComputerUpdates({ prisma, jobs }), jobs,
reconcileProfileConsolidation: createProfileConsolidationScheduler({ prisma, jobs }), reconcileCloudAgents: () => reconcileCloudAgents({ prisma, jobs, cloudAgent }),
reconcileDelegatedTasks: () => reconcileDelegatedTasks({ prisma, jobs, events }), reconcileComputerUpdates: () => reconcileComputerUpdates({ prisma, jobs }),
reconcileMediaJobs: () => media.reconcile(), reconcileProfileConsolidation: createProfileConsolidationScheduler({ prisma, jobs }),
}) reconcileDelegatedTasks: () => reconcileDelegatedTasks({ prisma, jobs, events }),
: undefined; })
: undefined;
reconciler?.start(); reconciler?.start();
const router = createRouter({ const router = createRouter({
cloudAgent, cloudAgent,
media,
prisma, prisma,
events, events,
auth, auth,
@ -515,13 +485,13 @@ export async function createApp(
secrets, secrets,
oauthLogins, oauthLogins,
integrationSettings, integrationSettings,
vapid,
mcpOAuth, mcpOAuth,
composio: stack.composio, composio: stack.composio,
connectors: stack.connector, connectors: stack.connector,
remoteConnectors, remoteConnectors,
artifacts, artifacts,
dataDir: env.dataDir, dataDir: env.dataDir,
mediaServices,
messaging: { messaging: {
enabled: Boolean(messaging), enabled: Boolean(messaging),
providers: messaging?.platforms().map((platform) => platform.provider) ?? [], providers: messaging?.platforms().map((platform) => platform.provider) ?? [],
@ -566,7 +536,7 @@ export async function createApp(
await next(); await next();
c.res.headers.set( c.res.headers.set(
"Content-Security-Policy", "Content-Security-Policy",
"default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; media-src 'self' blob:; font-src 'self'; connect-src 'self' ws:; frame-ancestors 'none'; base-uri 'self'; form-action 'self'", "default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; font-src 'self'; connect-src 'self' ws:; frame-ancestors 'none'; base-uri 'self'; form-action 'self'",
); );
c.res.headers.set("Strict-Transport-Security", "max-age=31536000; includeSubDomains"); c.res.headers.set("Strict-Transport-Security", "max-age=31536000; includeSubDomains");
c.res.headers.set("X-Frame-Options", "DENY"); c.res.headers.set("X-Frame-Options", "DENY");
@ -613,12 +583,7 @@ export async function createApp(
} }
const { matched, response } = await rpc.handle(c.req.raw, { const { matched, response } = await rpc.handle(c.req.raw, {
prefix: "/rpc", prefix: "/rpc",
context: { context: { actor, signal: c.req.raw.signal },
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); if (matched) return c.newResponse(response.body, response);
await next(); await next();
@ -634,12 +599,12 @@ export async function createApp(
if (actor) enrichLogContext({ "user.id": actor.userId, "space.id": actor.spaceId }); if (actor) enrichLogContext({ "user.id": actor.userId, "space.id": actor.spaceId });
return actor; return actor;
}); });
mountArtifactHttpRoutes(app, { mountMediaHttpRoutes(app, { services: mediaServices }, async (c) => {
prisma, const session = await auth.api.getSession({ headers: sessionHeaders(c.req.raw) });
artifacts, if (!session?.user) return null;
sessionUserId: async (request) => return requireMembership(prisma, session.user.id, c.req.header("x-rakazo-space-id")).catch(
(await auth.api.getSession({ headers: sessionHeaders(request) }))?.user?.id ?? null, () => null,
trustedOrigin: (origin) => isTrustedOrigin(origin, env), );
}); });
mountWebhookHttpRoutes(app, { prisma, secrets, events, jobs }); mountWebhookHttpRoutes(app, { prisma, secrets, events, jobs });
// Shared with stop so a shutdown during retry delays does not restart polling. // Shared with stop so a shutdown during retry delays does not restart polling.
@ -1011,7 +976,6 @@ export async function createApp(
await teamChatBridge?.stop(); await teamChatBridge?.stop();
await email?.drain?.(); await email?.drain?.();
await reconciler?.stop(); await reconciler?.stop();
await graphileWorkerHost?.stop();
await jobs.close(); await jobs.close();
await realtime.close(); await realtime.close();
await connector.stop(); await connector.stop();
@ -1027,7 +991,6 @@ export async function createApp(
function isTrustedOrigin(origin: string, env: AppEnv) { function isTrustedOrigin(origin: string, env: AppEnv) {
if (!origin) return true; if (!origin) return true;
if (origin === env.webOrigin || origin === env.apiUrl || origin === env.authUrl) 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; if (origin.startsWith("rakazo://") || origin.startsWith("exp://")) return true;
try { try {
const host = new URL(origin).hostname; const host = new URL(origin).hostname;
@ -1037,6 +1000,20 @@ 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) { function sessionHeaders(request: Request) {
const headers = new Headers(request.headers); const headers = new Headers(request.headers);
const authz = headers.get("authorization"); const authz = headers.get("authorization");
@ -1055,18 +1032,7 @@ function sessionHeaders(request: Request) {
* "fetch failed" and keep the host and errno one level down. * "fetch failed" and keep the host and errno one level down.
*/ */
export function logUnexpectedRpcError(error: unknown, path: readonly string[]): void { export function logUnexpectedRpcError(error: unknown, path: readonly string[]): void {
if (error instanceof ORPCError) return;
const where = `rpc ${path.join("/")} failed`; 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); getLogger().error(where, error);
} }

View File

@ -1,275 +0,0 @@
import { mkdtemp, readdir, rm } from "node:fs/promises";
import { createServer } from "node:http";
import { tmpdir } from "node:os";
import path from "node:path";
import type { ArtifactStore } from "@rakazo/adapter-kit";
import { LocalArtifactStore } from "@rakazo/adapters";
import type { PrismaClient } from "@rakazo/db";
import { Hono } from "hono";
import { afterEach, describe, expect, it, vi } from "vitest";
import {
API_SERVER_OPTIONS,
contentDisposition,
mountArtifactHttpRoutes,
parseByteRange,
stallGuardedChunks,
UploadStalledError,
} from "./artifact-http.js";
describe("parseByteRange", () => {
it("ignores an absent header or another unit", () => {
expect(parseByteRange(undefined, 100)).toBeNull();
expect(parseByteRange("", 100)).toBeNull();
expect(parseByteRange("items=0-1", 100)).toBeNull();
});
it("parses closed, open and suffix ranges and clamps the end to the size", () => {
expect(parseByteRange("bytes=0-9", 100)).toEqual({ start: 0, end: 9 });
expect(parseByteRange("bytes=90-", 100)).toEqual({ start: 90, end: 99 });
expect(parseByteRange("bytes=-10", 100)).toEqual({ start: 90, end: 99 });
expect(parseByteRange("bytes=-500", 100)).toEqual({ start: 0, end: 99 });
expect(parseByteRange("bytes=50-1000", 100)).toEqual({ start: 50, end: 99 });
expect(parseByteRange("Bytes= 0-0 ", 100)).toEqual({ start: 0, end: 0 });
});
it("rejects multiple, inverted, out-of-file and malformed ranges", () => {
for (const header of [
"bytes=0-1,5-6",
"bytes=5-4",
"bytes=100-",
"bytes=100-200",
"bytes=-0",
"bytes=-",
"bytes=a-b",
"bytes=1.5-2",
"bytes= -1-2",
"bytes=0x10-20",
`bytes=${"9".repeat(16)}-`,
]) {
expect(parseByteRange(header, 100), header).toBe("unsatisfiable");
}
expect(parseByteRange("bytes=0-0", 0)).toBe("unsatisfiable");
});
});
describe("contentDisposition", () => {
it("keeps plain names and marks media inline", () => {
expect(contentDisposition("clip.mp4", true)).toBe(
`inline; filename="clip.mp4"; filename*=UTF-8''clip.mp4`,
);
});
it("neutralizes quotes, separators, control characters and header injection", () => {
const header = contentDisposition('../evil"\r\nSet-Cookie: x=1;.txt', false);
expect(header.startsWith("attachment; ")).toBe(true);
expect(header).not.toMatch(/[\r\n]/);
expect(header).toContain(`filename=".._evil___Set-Cookie: x=1_.txt"`);
expect(header).not.toContain("/");
});
it("encodes non-ASCII names with an ASCII fallback", () => {
expect(contentDisposition("Grüße (1).png", true)).toBe(
`inline; filename="Gr__e (1).png"; filename*=UTF-8''Gr%C3%BC%C3%9Fe%20%281%29.png`,
);
expect(contentDisposition(" ", false)).toBe(
`attachment; filename="download"; filename*=UTF-8''download`,
);
});
});
describe("artifact HTTP routes", () => {
function app(sessionUserId: () => Promise<string | null>) {
const prisma = {
artifact: { findUnique: vi.fn(), create: vi.fn() },
spaceMember: { findFirst: vi.fn() },
};
const artifacts = { putStream: vi.fn(), stat: vi.fn(), createReadStream: vi.fn() };
const hono = new Hono();
mountArtifactHttpRoutes(hono, {
prisma: prisma as unknown as PrismaClient,
artifacts: artifacts as unknown as ArtifactStore,
sessionUserId,
trustedOrigin: (origin) => origin === "http://127.0.0.1:5173",
});
return { hono, prisma, artifacts };
}
it("rejects signed-out reads and uploads before touching storage", async () => {
const { hono, prisma, artifacts } = app(async () => null);
const read = await hono.request("/api/artifacts/art-1");
const upload = await hono.request("/api/artifacts?botId=bot-1&name=a.mp4", {
method: "POST",
headers: { "content-type": "video/mp4" },
body: "data",
});
expect(read.status).toBe(401);
expect(upload.status).toBe(401);
expect(prisma.artifact.findUnique).not.toHaveBeenCalled();
expect(artifacts.putStream).not.toHaveBeenCalled();
});
it("refuses uploads from untrusted origins before reading the session", async () => {
const sessionUserId = vi.fn(async () => "user-1");
const { hono, artifacts } = app(sessionUserId);
const response = await hono.request("/api/artifacts?botId=bot-1&name=a.txt", {
method: "POST",
headers: { "content-type": "text/plain", origin: "https://evil.example" },
body: "data",
});
expect(response.status).toBe(403);
expect(sessionUserId).not.toHaveBeenCalled();
expect(artifacts.putStream).not.toHaveBeenCalled();
});
it("answers ids that are not artifact ids with 404 without a lookup", async () => {
const { hono, prisma } = app(async () => "user-1");
const response = await hono.request(`/api/artifacts/${encodeURIComponent("../etc/passwd")}`);
expect(response.status).toBe(404);
expect(prisma.artifact.findUnique).not.toHaveBeenCalled();
});
it("rejects declared oversized and disallowed uploads without reading the body", async () => {
const { hono, prisma, artifacts } = app(async () => "user-1");
prisma.spaceMember.findFirst.mockResolvedValue({
userId: "user-1",
spaceId: "space-1",
member: { user: { email: "user@example.test" } },
});
(prisma as unknown as { deploymentSettings: unknown }).deploymentSettings = {
findUnique: vi.fn(async () => null),
};
const cancel = vi.fn(() => new Promise<void>(() => undefined));
const oversized = await hono.request(
new Request("http://localhost/api/artifacts?botId=bot-1&name=a.mp4", {
method: "POST",
headers: { "content-type": "video/mp4", "content-length": String(501 * 1024 * 1024) },
body: new ReadableStream({ cancel }),
duplex: "half",
} as RequestInit & { duplex: "half" }),
);
expect(oversized.status).toBe(413);
expect(cancel).toHaveBeenCalled();
const html = await hono.request("/api/artifacts?botId=bot-1&name=a.html", {
method: "POST",
headers: { "content-type": "text/html" },
body: "<script></script>",
});
expect(html.status).toBe(415);
const noTarget = await hono.request("/api/artifacts?name=a.mp4", {
method: "POST",
headers: { "content-type": "video/mp4" },
body: "data",
});
expect(noTarget.status).toBe(400);
expect(artifacts.putStream).not.toHaveBeenCalled();
});
});
describe("upload timeouts", () => {
const dirs: string[] = [];
afterEach(async () => {
await Promise.all(dirs.splice(0).map((dir) => rm(dir, { recursive: true, force: true })));
});
it("gives the server time for large uploads but keeps the header timeout", () => {
const server = createServer(API_SERVER_OPTIONS);
expect(server.requestTimeout).toBe(2 * 60 * 60 * 1000);
expect(server.headersTimeout).toBe(createServer().headersTimeout);
});
/** A body that sends `chunks` at `gapMs` intervals, then stays open forever if `hang`. */
function drip(chunks: string[], gapMs: number, hang: boolean) {
const cancel = vi.fn(() => new Promise<void>(() => undefined));
let sent = 0;
const body = new ReadableStream<Uint8Array>({
async pull(controller) {
if (sent === chunks.length) {
if (!hang) controller.close();
else await new Promise(() => undefined);
return;
}
if (sent > 0) await new Promise((resolve) => setTimeout(resolve, gapMs));
controller.enqueue(new TextEncoder().encode(chunks[sent++]));
},
cancel,
});
return { body, cancel };
}
it("throws once the body stops sending and cancels it without waiting", async () => {
const { body, cancel } = drip(["ab", "cd"], 5, true);
const seen: string[] = [];
await expect(
(async () => {
for await (const chunk of stallGuardedChunks(body, 50)) {
seen.push(new TextDecoder().decode(chunk));
}
})(),
).rejects.toBeInstanceOf(UploadStalledError);
expect(seen).toEqual(["ab", "cd"]);
expect(cancel).toHaveBeenCalled();
});
async function postDrip(stallMs: number, body: ReadableStream<Uint8Array>) {
const root = await mkdtemp(path.join(tmpdir(), "rakazo-artifact-upload-"));
dirs.push(root);
const create = vi.fn(async ({ data }: { data: Record<string, unknown> }) => ({
id: "art-1",
createdAt: new Date(),
...data,
}));
const prisma = {
artifact: { create },
spaceMember: {
findFirst: vi.fn(async () => ({
userId: "user-1",
spaceId: "space-1",
member: { user: { email: "user@example.test" } },
})),
},
deploymentSettings: { findUnique: vi.fn(async () => null) },
bot: { findFirst: vi.fn(async () => ({ id: "bot-1" })) },
};
const hono = new Hono();
mountArtifactHttpRoutes(hono, {
prisma: prisma as unknown as PrismaClient,
artifacts: new LocalArtifactStore(root),
sessionUserId: async () => "user-1",
trustedOrigin: () => true,
uploadStallTimeoutMs: stallMs,
});
const response = await hono.request(
new Request("http://localhost/api/artifacts?botId=bot-1&name=clip.mp4", {
method: "POST",
headers: { "content-type": "video/mp4" },
body,
duplex: "half",
} as RequestInit & { duplex: "half" }),
);
// The store creates the space directory before it reads the body, so it must exist here.
const stored = await readdir(path.join(root, "artifacts", "space-1"));
return { response, create, stored };
}
it("aborts a stalled upload with 408 and deletes the partial file", async () => {
const { body, cancel } = drip(["partial video bytes"], 0, true);
const { response, create, stored } = await postDrip(50, body);
expect(response.status).toBe(408);
expect(await response.json()).toEqual({ error: "Upload stalled." });
expect(cancel).toHaveBeenCalled();
expect(create).not.toHaveBeenCalled();
expect(stored).toEqual([]);
});
it("keeps a slow but steady upload going past the stall timeout", async () => {
const { body } = drip(["a", "b", "c", "d", "e", "f"], 30, false);
const { response, create, stored } = await postDrip(100, body);
expect(response.status).toBe(201);
expect(create).toHaveBeenCalledWith({
data: expect.objectContaining({ size: 6, mimeType: "video/mp4", botId: "bot-1" }),
});
expect(stored).toHaveLength(1);
});
});

View File

@ -1,336 +0,0 @@
import type { AdapterContext, ArtifactByteRange, ArtifactStore } from "@rakazo/adapter-kit";
import { ArtifactTooLargeError } from "@rakazo/adapter-kit";
import { storeArtifactStream } from "@rakazo/adapters";
import type { Actor } 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";
import { cancelBody } from "./http-body.js";
export const ARTIFACT_HTTP_PATH = "/api/artifacts";
/**
* 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: 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;
export class UploadStalledError extends Error {
constructor() {
super("Upload stalled");
this.name = "UploadStalledError";
}
}
/**
* Yields the body's chunks and throws UploadStalledError when the next chunk takes longer than
* stallMs. Only time spent waiting for the client counts, not time spent writing to disk.
*/
export async function* stallGuardedChunks(
body: ReadableStream<Uint8Array>,
stallMs: number,
): AsyncGenerator<Uint8Array> {
const reader = body.getReader();
try {
for (;;) {
let timer: ReturnType<typeof setTimeout> | undefined;
const stalled = new Promise<never>((_, reject) => {
timer = setTimeout(() => reject(new UploadStalledError()), stallMs);
});
try {
const { done, value } = await Promise.race([reader.read(), stalled]);
if (done) return;
yield value;
} finally {
clearTimeout(timer);
}
}
} finally {
// Never wait on the client here: a stalled or hostile body may never confirm the cancel.
void reader.cancel().catch(() => undefined);
reader.releaseLock();
}
}
export type ArtifactHttpDeps = {
prisma: PrismaClient;
artifacts: ArtifactStore;
/** The signed-in user (cookie or bearer session), or null. */
sessionUserId: (request: Request) => Promise<string | null>;
/** Same rule as CORS. Raw uploads can be CORS "simple" requests, so the origin is checked. */
trustedOrigin: (origin: string) => boolean;
/** Overrides ARTIFACT_UPLOAD_STALL_TIMEOUT_MS (tests). */
uploadStallTimeoutMs?: number;
};
/** Generated ids only; anything else is not an artifact. */
const ARTIFACT_ID = /^[A-Za-z0-9_-]{1,64}$/;
const MAX_RANGE_DIGITS = 15;
/**
* Parses a single `bytes=` range against the stored size. Returns null when the header should
* be ignored (absent or another unit) and "unsatisfiable" for multiple ranges or offsets
* outside the file, which the caller answers with 416.
*/
export function parseByteRange(
header: string | undefined,
size: number,
): ArtifactByteRange | "unsatisfiable" | null {
if (!header) return null;
const value = header.trim();
if (!value.toLowerCase().startsWith("bytes=")) return null;
const spec = value.slice("bytes=".length).trim();
const match = new RegExp(`^(\\d{0,${MAX_RANGE_DIGITS}})-(\\d{0,${MAX_RANGE_DIGITS}})$`).exec(
spec,
);
if (!match || size <= 0) return "unsatisfiable";
const [, first = "", last = ""] = match;
if (first === "" && last === "") return "unsatisfiable";
if (first === "") {
const suffix = Number(last);
if (suffix <= 0) return "unsatisfiable";
return { start: Math.max(0, size - suffix), end: size - 1 };
}
const start = Number(first);
const end = last === "" ? size - 1 : Number(last);
if (start >= size || end < start) return "unsatisfiable";
return { start, end: Math.min(end, size - 1) };
}
/** `inline` or `attachment` with an ASCII fallback and the exact UTF-8 name (RFC 6266). */
export function contentDisposition(name: string, inline: boolean): string {
const cleaned =
Array.from(name, (char) => {
const code = char.codePointAt(0) ?? 0;
return code < 0x20 || code === 0x7f || char === "/" || char === "\\" ? "_" : char;
})
.join("")
.trim() || "download";
const ascii = cleaned.replace(/[^ -~]/g, "_").replace(/["%;]/g, "_");
const encoded = encodeURIComponent(cleaned).replace(
/['()*]/g,
(char) => `%${char.charCodeAt(0).toString(16).toUpperCase()}`,
);
return `${inline ? "inline" : "attachment"}; filename="${ascii}"; filename*=UTF-8''${encoded}`;
}
function adapterContext(actor: Actor, operationId: string, signal: AbortSignal): AdapterContext {
return {
operationId,
traceId: operationId,
spaceId: actor.spaceId,
userId: actor.userId,
signal,
};
}
function notFound(c: Context) {
return c.json({ error: "Not found" }, 404);
}
function artifactDto(row: {
id: string;
botId: string | null;
groupId: string | null;
runId: string | null;
name: string;
mimeType: string;
size: number;
createdAt: Date;
}) {
return {
id: row.id,
botId: row.botId,
groupId: row.groupId,
runId: row.runId,
name: row.name,
mimeType: row.mimeType,
size: row.size,
createdAt: row.createdAt.toISOString(),
};
}
/**
* Streams artifacts over HTTP so media can play with seeking and large files never travel as
* base64 RPC payloads. Authorization matches `artifacts.get` and `artifacts.create`.
*/
export function mountArtifactHttpRoutes(app: Hono, deps: ArtifactHttpDeps) {
const repos = createRepos(deps.prisma);
const groupRepos = createGroupRepos(deps.prisma);
/** Same rule as artifacts.get: the owner's artifact of an active bot thread or group. */
async function readableArtifact(
userId: string,
requestedSpaceId: string | undefined,
id: string,
) {
if (!ARTIFACT_ID.test(id)) return null;
const row = await deps.prisma.artifact.findUnique({ where: { id } });
if (!row) return null;
// <video> and <img> cannot send the space header, so the artifact's space is the default;
// membership is still required and an explicit header must name the same space.
const actor = await requireMembership(
deps.prisma,
userId,
requestedSpaceId ?? row.spaceId,
).catch(() => null);
if (!actor || actor.spaceId !== row.spaceId || actor.userId !== row.userId) return null;
try {
if (row.groupId) await groupRepos.getGroupTarget(actor, row.groupId);
else if (row.botId) await repos.getBot(actor, row.botId);
else return null;
} catch (error) {
if (error instanceof IsolationError) return null;
throw error;
}
return { row, actor };
}
app.get(`${ARTIFACT_HTTP_PATH}/:id`, async (c) => {
const userId = await deps.sessionUserId(c.req.raw);
if (!userId) return c.json({ error: "Unauthorized" }, 401);
const found = await readableArtifact(
userId,
c.req.header("x-rakazo-space-id"),
c.req.param("id"),
);
if (!found) return notFound(c);
const { row, actor } = found;
const context = adapterContext(actor, `artifact-http-get:${row.id}`, c.req.raw.signal);
let size: number;
try {
({ size } = await deps.artifacts.stat(row.storageKey, context));
} catch {
return notFound(c);
}
const mimeType = isAllowedAttachmentMimeType(row.mimeType)
? row.mimeType
: "application/octet-stream";
const inline = attachmentKindOf(mimeType) !== "file" && c.req.query("download") === undefined;
const etag = /^[A-Za-z0-9]{16,128}$/.test(row.hash) ? `"${row.hash}"` : undefined;
const headers = new Headers({
"accept-ranges": "bytes",
// Revalidate every time so a revoked thread stops serving from the browser cache.
"cache-control": "private, no-cache",
"content-disposition": contentDisposition(row.name, inline),
"content-type": mimeType,
"cross-origin-resource-policy": "same-origin",
"x-content-type-options": "nosniff",
});
if (etag) headers.set("etag", etag);
if (etag && c.req.header("if-none-match") === etag) {
return new Response(null, { status: 304, headers });
}
const ifRange = c.req.header("if-range");
const range =
ifRange === undefined || ifRange === etag
? parseByteRange(c.req.header("range"), size)
: null;
if (range === "unsatisfiable") {
headers.set("content-range", `bytes */${size}`);
return new Response(null, { status: 416, headers });
}
const status = range ? 206 : 200;
headers.set("content-length", String(range ? range.end - range.start + 1 : size));
if (range) headers.set("content-range", `bytes ${range.start}-${range.end}/${size}`);
// Hono answers HEAD from this handler and drops the body; never open a stream for it.
if (c.req.method === "HEAD") return new Response(null, { status, headers });
let body: ReadableStream<Uint8Array>;
try {
body = await deps.artifacts.createReadStream(row.storageKey, context, range ?? undefined);
} catch {
return notFound(c);
}
return new Response(body, { status, headers });
});
app.post(ARTIFACT_HTTP_PATH, async (c) => {
const request = c.req.raw;
const reject = (status: 400 | 401 | 403 | 404 | 408 | 413 | 415, error: string) => {
cancelBody(request.body);
return c.json({ error }, status);
};
const origin = c.req.header("origin");
if (origin && !deps.trustedOrigin(origin)) return reject(403, "Forbidden");
const userId = await deps.sessionUserId(request);
if (!userId) return reject(401, "Unauthorized");
const actor = await requireMembership(
deps.prisma,
userId,
c.req.header("x-rakazo-space-id"),
).catch(() => null);
if (!actor) return reject(401, "Unauthorized");
const botId = c.req.query("botId") || undefined;
const groupId = c.req.query("groupId") || undefined;
const name = c.req.query("name") ?? "";
if (Boolean(botId) === Boolean(groupId)) {
return reject(400, "Provide exactly one of botId or groupId");
}
if (name.length < 1 || name.length > 255) return reject(400, "Invalid file name");
const mimeType = (c.req.header("content-type") ?? "").split(";", 1)[0]!.trim().toLowerCase();
if (!isAllowedAttachmentMimeType(mimeType)) {
return reject(415, "Unsupported attachment type");
}
const maxBytes = artifactMaxBytes(mimeType);
const declared = c.req.header("content-length");
if (declared !== undefined) {
if (!/^\d{1,16}$/.test(declared)) return reject(400, "Invalid content length");
if (Number(declared) > maxBytes) return reject(413, "File is too large.");
if (Number(declared) === 0) return reject(400, "File is empty.");
}
if (!request.body) return reject(400, "File is empty.");
// Same targets as artifacts.create: an own active bot, or an active group of own bots.
let ownerBotId: string | undefined;
try {
ownerBotId = botId
? (await repos.getBot(actor, botId)).id
: (await groupRepos.getGroupTarget(actor, groupId!)).members[0]?.bot.id;
} catch (error) {
if (!(error instanceof IsolationError)) throw error;
}
if (!ownerBotId) return reject(404, "Not found");
let row: Awaited<ReturnType<typeof storeArtifactStream>>;
try {
row = await storeArtifactStream(deps, {
spaceId: actor.spaceId,
userId: actor.userId,
botId: ownerBotId,
groupId: groupId ?? null,
runId: null,
name,
mimeType,
stream: stallGuardedChunks(
request.body,
deps.uploadStallTimeoutMs ?? ARTIFACT_UPLOAD_STALL_TIMEOUT_MS,
),
maxBytes,
signal: request.signal,
});
} catch (error) {
if (error instanceof ArtifactTooLargeError) return reject(413, "File is too large.");
if (error instanceof UploadStalledError) return reject(408, "Upload stalled.");
throw error;
}
if (row.size === 0) {
await deps.prisma.artifact.delete({ where: { id: row.id } });
await deps.artifacts
.remove(
row.storageKey,
adapterContext(actor, `artifact-http-post:${row.id}`, request.signal),
)
.catch(() => undefined);
return c.json({ error: "File is empty." }, 400);
}
return c.json(artifactDto(row), 201);
});
}

View File

@ -11,12 +11,6 @@ import {
} from "@rakazo/core"; } from "@rakazo/core";
import { IsolationError, type PrismaClient } from "@rakazo/db"; import { IsolationError, type PrismaClient } from "@rakazo/db";
/**
* Base64 reads load the whole file into memory; larger media is served by
* GET /api/artifacts/:id instead.
*/
export const ARTIFACT_INLINE_READ_MAX_BYTES = 25 * 1024 * 1024;
function adapterContext(actor: Actor, botId: string, operationId: string) { function adapterContext(actor: Actor, botId: string, operationId: string) {
return { return {
operationId, operationId,
@ -137,9 +131,6 @@ async function readArtifact(
}, },
contextBotId: string, contextBotId: string,
) { ) {
if (row.size > ARTIFACT_INLINE_READ_MAX_BYTES) {
throw new AttachmentValidationError("This file is too large to load inline.");
}
const bytes = await artifacts.get( const bytes = await artifacts.get(
row.storageKey, row.storageKey,
adapterContext(actor, contextBotId, `artifact-get:${row.id}`), adapterContext(actor, contextBotId, `artifact-get:${row.id}`),

View File

@ -1,106 +0,0 @@
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,
}));
}

View File

@ -1,74 +0,0 @@
import { describe, expect, it } from "vitest";
import { importableRoutines, relativeImportedFiles, safeImportPath } from "./bot-import.js";
describe("safeImportPath", () => {
it("normalizes separators and dot segments", () => {
expect(safeImportPath("notes\\./result.txt")).toBe("notes/result.txt");
expect(safeImportPath("MEMORY.md")).toBe("MEMORY.md");
});
it("rejects paths that could leave the bot home", () => {
expect(safeImportPath("../secrets")).toBeNull();
expect(safeImportPath("notes/../../x")).toBeNull();
expect(safeImportPath("/etc/passwd")).toBeNull();
expect(safeImportPath("C:\\Windows")).toBeNull();
expect(safeImportPath("./")).toBeNull();
});
});
describe("relativeImportedFiles", () => {
it("drops the old Team Computer workspace prefix", () => {
expect(
relativeImportedFiles([
{ path: "bots/old-id/notes/result.txt", content: "a" },
{ path: "bots/old-id/README.md", content: "b" },
]),
).toEqual([
{ path: "notes/result.txt", content: "a" },
{ path: "README.md", content: "b" },
]);
});
it("keeps Private Computer paths as they are", () => {
const files = [
{ path: "notes/result.txt", content: "a" },
{ path: "bots/other/file.txt", content: "b" },
];
expect(relativeImportedFiles(files)).toEqual(files);
});
it("keeps paths when files span several workspaces", () => {
const files = [
{ path: "bots/a/x.txt", content: "a" },
{ path: "bots/b/y.txt", content: "b" },
];
expect(relativeImportedFiles(files)).toEqual(files);
});
it("refuses the whole set when one path is unsafe", () => {
expect(
relativeImportedFiles([
{ path: "ok.txt", content: "a" },
{ path: "../escape.txt", content: "b" },
]),
).toBeNull();
});
it("accepts an export without files", () => {
expect(relativeImportedFiles([])).toEqual([]);
});
});
describe("importableRoutines", () => {
it("leaves out one-shot schedules", () => {
const routines = [
{ name: "daily", crons: ["0 9 * * *"] },
{ name: "once", crons: ["@once"] },
{ name: "webhook", crons: [] },
];
expect(importableRoutines(routines).map((routine) => routine.name)).toEqual([
"daily",
"webhook",
]);
});
});

View File

@ -1,34 +0,0 @@
import { isOneShotRoutineCrons } from "@rakazo/core";
/** A relative, forward-slash path without `.`/`..` segments, or null when it could escape. */
export function safeImportPath(raw: string): string | null {
const normalized = raw.replace(/\\/g, "/");
if (normalized.startsWith("/") || /^[a-z]:/i.test(normalized)) return null;
const segments = normalized.split("/").filter((segment) => segment !== "" && segment !== ".");
if (segments.length === 0 || segments.includes("..")) return null;
return segments.join("/");
}
/**
* Exports of bots on a Team Computer carry their workspace prefix (`bots/<old id>/…`).
* The prefix names the old bot, so it is dropped and the importer writes under the new one.
*/
export function relativeImportedFiles<T extends { path: string }>(
files: T[],
): Array<T & { path: string }> | null {
const safe: Array<T & { path: string }> = [];
for (const file of files) {
const path = safeImportPath(file.path);
if (!path) return null;
safe.push({ ...file, path });
}
const first = safe[0]?.path.split("/");
const workspace = first && first.length > 2 && first[0] === "bots" ? `bots/${first[1]}/` : null;
if (!workspace || !safe.every((file) => file.path.startsWith(workspace))) return safe;
return safe.map((file) => ({ ...file, path: file.path.slice(workspace.length) }));
}
/** One-shot schedules belong to the chat that set them, so they are not carried over. */
export function importableRoutines<T extends { crons: string[] }>(routines: T[]): T[] {
return routines.filter((routine) => !isOneShotRoutineCrons(routine.crons));
}

View File

@ -1,96 +0,0 @@
import { ORPCError } from "@orpc/server";
import type { JobPublisher } from "@rakazo/adapter-kit";
import { getCoachRhythm, setCoachRhythm } from "@rakazo/adapters";
import type {
Actor,
CheckIn,
CoachRhythm,
CoachToday,
Goal,
Habit,
LogCheckIn,
} from "@rakazo/contracts";
import type { CheckInFilter, GoalPatch, HabitPatch, PrismaClient } from "@rakazo/db";
import {
CoachNotFoundError,
createGoal,
createHabit,
InvalidCoachInputError,
listCheckIns,
listGoals,
listHabits,
loadCoachToday,
logCheckIn,
removeGoal,
removeHabit,
updateGoal,
updateHabit,
} from "@rakazo/db";
export interface CoachDeps {
prisma: PrismaClient;
jobs: JobPublisher;
}
/** Another user's ids read as missing, so a caller cannot probe them. */
async function mapErrors<T>(work: () => Promise<T>): Promise<T> {
try {
return await work();
} catch (error) {
if (error instanceof CoachNotFoundError) {
throw new ORPCError("NOT_FOUND", { message: error.message });
}
if (error instanceof InvalidCoachInputError) {
throw new ORPCError("BAD_REQUEST", { message: error.message });
}
throw error;
}
}
/** Coach procedures for the signed-in user only; nothing here is space-scoped. */
export function createCoachService(deps: CoachDeps) {
const { prisma } = deps;
return {
today: (actor: Actor): Promise<CoachToday> => loadCoachToday(prisma, actor.userId),
listGoals: (actor: Actor, input: { status?: Goal["status"] }) =>
listGoals(prisma, actor.userId, input),
createGoal: (
actor: Actor,
input: {
title: string;
detail?: string;
targetDate?: string | null;
spaceId?: string | null;
},
) => mapErrors(() => createGoal(prisma, actor.userId, { ...input, source: "user" })),
updateGoal: (actor: Actor, { goalId, ...patch }: GoalPatch & { goalId: string }) =>
mapErrors(() => updateGoal(prisma, actor.userId, goalId, patch)),
removeGoal: async (actor: Actor, input: { goalId: string }) => {
await mapErrors(() => removeGoal(prisma, actor.userId, input.goalId));
return { ok: true as const };
},
listHabits: (actor: Actor, input: { includeInactive?: boolean }): Promise<Habit[]> =>
listHabits(prisma, actor.userId, input),
createHabit: (
actor: Actor,
input: { title: string; cadence: Habit["cadence"]; goalId?: string | null },
) => mapErrors(() => createHabit(prisma, actor.userId, { ...input, source: "user" })),
updateHabit: (actor: Actor, { habitId, ...patch }: HabitPatch & { habitId: string }) =>
mapErrors(() => updateHabit(prisma, actor.userId, habitId, patch)),
removeHabit: async (actor: Actor, input: { habitId: string }) => {
await mapErrors(() => removeHabit(prisma, actor.userId, input.habitId));
return { ok: true as const };
},
logCheckIn: (actor: Actor, input: LogCheckIn): Promise<CheckIn> =>
mapErrors(() => logCheckIn(prisma, actor.userId, input)),
listCheckIns: (actor: Actor, input: CheckInFilter) =>
mapErrors(() => listCheckIns(prisma, actor.userId, input)),
getRhythm: (actor: Actor): Promise<CoachRhythm> => getCoachRhythm(prisma, actor.userId),
setRhythm: (actor: Actor, rhythm: CoachRhythm): Promise<CoachRhythm> =>
setCoachRhythm(deps, { userId: actor.userId, rhythm }),
};
}

View File

@ -1,50 +0,0 @@
import { FEATURED_CONNECTOR_LABELS, matchFeaturedConnectorId } from "@rakazo/core";
import type { PrismaClient } from "@rakazo/db";
import { updateUserProfile } from "@rakazo/db";
import { getLogger } from "@rakazo/logging";
/**
* The app's own name: a featured app's label, otherwise the label of the user's first account for
* it (later accounts may be named "Gmail 2" or renamed).
*/
async function connectedAppName(
prisma: PrismaClient,
connection: { userId: string; connectorId: string; provider: string },
): Promise<string | null> {
const featured = matchFeaturedConnectorId(connection.provider);
if (featured) return FEATURED_CONNECTOR_LABELS[featured];
const first = await prisma.connection.findFirst({
where: {
userId: connection.userId,
connectorId: connection.connectorId,
provider: connection.provider,
},
orderBy: [{ createdAt: "asc" }, { id: "asc" }],
select: { displayName: true },
});
const name = first?.displayName.trim().slice(0, 80);
return name || null;
}
/**
* Remembers "Uses <App>" in the user's profile when one of their connections becomes connected.
* Revoking the connection keeps the fact; the user removes it in About you. Best effort: the
* connection itself has already succeeded, so a full profile or a database error only logs.
*/
export async function recordConnectedApp(
prisma: PrismaClient,
connection: { userId: string; connectorId: string; provider: string },
): Promise<void> {
try {
const name = await connectedAppName(prisma, connection);
if (!name) return;
await updateUserProfile(prisma, connection.userId, {
addFacts: [{ category: "tools", value: `Uses ${name}`, source: "connector" }],
});
} catch (error) {
getLogger().error("connected app fact was not recorded", error, {
connectorId: connection.connectorId,
provider: connection.provider,
});
}
}

View File

@ -1,4 +1,6 @@
import type { MediaConfig } from "@rakazo/adapters";
import { import {
mediaConfigFromEnv,
resolveCloudAgentProvider, resolveCloudAgentProvider,
resolveDeploymentModel, resolveDeploymentModel,
resolveSandboxProvider, resolveSandboxProvider,
@ -14,13 +16,14 @@ export { resolveCloudAgentProvider, resolveSandboxProvider } from "@rakazo/adapt
export interface AppEnv { export interface AppEnv {
nodeEnv: string; nodeEnv: string;
/** Media Studio; disabled unless a sidecar (or the scripted emulator) is configured. */
media: MediaConfig;
desktopStackToken?: string; desktopStackToken?: string;
databaseUrl: string; databaseUrl: string;
realtimeDatabaseUrl: string; realtimeDatabaseUrl: string;
authSecret: string; authSecret: string;
authUrl: string; authUrl: string;
webOrigin: string; webOrigin: string;
extraTrustedOrigins: string[];
privacyPolicyUrl?: string; privacyPolicyUrl?: string;
apiUrl: string; apiUrl: string;
apiHost: string; apiHost: string;
@ -104,16 +107,13 @@ export function loadEnv(source: NodeJS.ProcessEnv = process.env): AppEnv {
const updaterToken = optional(source.RAKAZO_UPDATER_TOKEN); const updaterToken = optional(source.RAKAZO_UPDATER_TOKEN);
return { return {
nodeEnv: source.NODE_ENV ?? "", nodeEnv: source.NODE_ENV ?? "",
media: mediaConfigFromEnv(source),
databaseUrl: required(source, "DATABASE_URL"), databaseUrl: required(source, "DATABASE_URL"),
realtimeDatabaseUrl: source.REALTIME_DATABASE_URL ?? required(source, "DATABASE_URL"), realtimeDatabaseUrl: source.REALTIME_DATABASE_URL ?? required(source, "DATABASE_URL"),
desktopStackToken: optional(source.RAKAZO_DESKTOP_STACK_TOKEN), desktopStackToken: optional(source.RAKAZO_DESKTOP_STACK_TOKEN),
authSecret, authSecret,
authUrl: source.BETTER_AUTH_URL ?? source.WEB_ORIGIN ?? "http://127.0.0.1:5173", authUrl: source.BETTER_AUTH_URL ?? source.WEB_ORIGIN ?? "http://127.0.0.1:5173",
webOrigin: 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), privacyPolicyUrl: optional(source.PRIVACY_POLICY_URL),
apiUrl: source.API_URL ?? "http://127.0.0.1:3100", apiUrl: source.API_URL ?? "http://127.0.0.1:3100",
apiHost: source.API_HOST ?? "127.0.0.1", apiHost: source.API_HOST ?? "127.0.0.1",

View File

@ -1,10 +1,8 @@
import { buildSuggestionCard, withSuggestionStatus } from "@rakazo/core";
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { import {
buildInboxItems, buildInboxItems,
type InboxMessageRow, type InboxMessageRow,
type InboxRunRow, type InboxRunRow,
type InboxSuggestionRow,
type InboxUnreadThreadRow, type InboxUnreadThreadRow,
} from "./inbox.js"; } from "./inbox.js";
@ -20,11 +18,9 @@ function run(
status: "waiting_input" | "waiting_takeover", status: "waiting_input" | "waiting_takeover",
thread: Partial<InboxRunRow["thread"]> = {}, thread: Partial<InboxRunRow["thread"]> = {},
bot = scout, bot = scout,
spaceId = "space-home",
): InboxRunRow { ): InboxRunRow {
return { return {
id, id,
spaceId,
status, status,
updatedAt: at(0), updatedAt: at(0),
bot, bot,
@ -52,10 +48,8 @@ const approval = {
function unreadThread( function unreadThread(
bot: InboxUnreadThreadRow["bot"], bot: InboxUnreadThreadRow["bot"],
latest?: { id: string; text: string; minute: number }, latest?: { id: string; text: string; minute: number },
spaceId = "space-home",
): InboxUnreadThreadRow { ): InboxUnreadThreadRow {
return { return {
spaceId,
createdAt: at(0), createdAt: at(0),
bot, bot,
messages: latest messages: latest
@ -233,105 +227,4 @@ describe("buildInboxItems", () => {
}); });
expect(items[2]).toMatchObject({ messageId: null, text: "", createdAt: at(0).toISOString() }); expect(items[2]).toMatchObject({ messageId: null, text: "", createdAt: at(0).toISOString() });
}); });
it("lists asks from another space's main conversation, but not the open space's own", () => {
const project = { id: "space-launch", name: "Launch" };
const main = (groupId: string) => ({
botId: null,
groupId,
group: { kind: "main", archivedAt: null },
});
const items = buildInboxItems({
runs: [
run("home-main", "waiting_input", main("group-main-home")),
run("project-main", "waiting_input", main("group-main-launch"), writer, project.id),
],
messages: [
message("ask-home", "home-main", [question], 1),
message("ask-project", "project-main", [question], 2),
],
unreadThreads: [],
otherSpaces: new Map([[project.id, project]]),
});
expect(items).toHaveLength(1);
expect(items[0]).toMatchObject({
runId: "project-main",
space: project,
target: { groupId: "group-main-launch" },
});
});
it("names the space of items from the user's other spaces", () => {
const side = { id: "space-side", name: "Side project" };
const items = buildInboxItems({
runs: [
run("run-home", "waiting_input"),
run("run-side", "waiting_input", {}, writer, side.id),
run("takeover-side", "waiting_takeover", {}, writer, side.id),
],
messages: [
message("ask-home", "run-home", [question], 1),
message("ask-side", "run-side", [question], 2),
],
unreadThreads: [
unreadThread(
{ id: "bot-quiet", name: "Quiet", color: "#3EC5A8" },
{ id: "quiet-latest", text: "Done", minute: 3 },
side.id,
),
],
otherSpaces: new Map([[side.id, side]]),
});
expect(items.map((item) => [item.kind, item.space?.name ?? null])).toEqual([
["takeover", "Side project"],
["ask", null],
["ask", "Side project"],
["unread", "Side project"],
]);
expect(items[1]).not.toHaveProperty("space");
expect(items[2]).toMatchObject({ space: side, target: { botId: writer.id } });
});
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([]);
});
}); });

View File

@ -1,23 +1,20 @@
import type { Actor, InboxItem, InboxThreadTarget } from "@rakazo/contracts"; import type { Actor, InboxItem, InboxThreadTarget } from "@rakazo/contracts";
import { MessageBlock, SUGGESTION_TTL_MS } from "@rakazo/contracts"; import { MessageBlock } from "@rakazo/contracts";
import { isApprovalAskBlock } from "@rakazo/core"; import { isApprovalAskBlock } from "@rakazo/core";
import type { PrismaClient } from "@rakazo/db"; import type { PrismaClient } from "@rakazo/db";
import { promptSnippet } from "./runs.js"; import { promptSnippet } from "./runs.js";
/** Caps per query; the user's spaces rarely hold more waiting runs or unread threads than this. */ /** Caps per query; a space rarely holds more waiting runs or unread threads than this. */
const INBOX_RUN_LIMIT = 50; const INBOX_RUN_LIMIT = 50;
const INBOX_UNREAD_LIMIT = 50; const INBOX_UNREAD_LIMIT = 50;
const INBOX_PREVIEW_LENGTH = 280; const INBOX_PREVIEW_LENGTH = 280;
type AskBlock = Extract<MessageBlock, { kind: "ask" }>; type AskBlock = Extract<MessageBlock, { kind: "ask" }>;
type CardBlock = Extract<MessageBlock, { kind: "card" }>;
type ComputerBlock = Extract<MessageBlock, { kind: "computer" }>; type ComputerBlock = Extract<MessageBlock, { kind: "computer" }>;
type InboxBot = { id: string; name: string; color: string }; type InboxBot = { id: string; name: string; color: string };
type InboxSpace = NonNullable<InboxItem["space"]>;
export interface InboxRunRow { export interface InboxRunRow {
id: string; id: string;
spaceId: string;
status: string; status: string;
updatedAt: Date; updatedAt: Date;
bot: InboxBot; bot: InboxBot;
@ -36,25 +33,12 @@ export interface InboxMessageRow {
} }
export interface InboxUnreadThreadRow { export interface InboxUnreadThreadRow {
spaceId: string;
createdAt: Date; createdAt: Date;
bot: InboxBot | null; bot: InboxBot | null;
/** The newest bot message, if any. */ /** The newest bot message, if any. */
messages: Array<Omit<InboxMessageRow, "runId">>; 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[] { function parseBlocks(blocks: unknown): MessageBlock[] {
const parsed = MessageBlock.array().safeParse(blocks); const parsed = MessageBlock.array().safeParse(blocks);
return parsed.success ? parsed.data : []; return parsed.success ? parsed.data : [];
@ -89,14 +73,12 @@ function previewText(blocks: unknown): string {
} }
/** /**
* Where an ask can be answered from. The open space's main conversation already shows its own * Where an ask can be answered from. The main conversation already shows its own asks, and an
* asks (a project's main conversation does not), and an archived group or an external * archived group or an external conversation has no thread target to answer through.
* conversation has no thread target to answer through.
*/ */
function askTarget(thread: InboxRunRow["thread"], currentSpace: boolean): InboxThreadTarget | null { function askTarget(thread: InboxRunRow["thread"]): InboxThreadTarget | null {
if (thread.groupId) { if (thread.groupId) {
if (!thread.group || thread.group.archivedAt) return null; if (!thread.group || thread.group.kind === "main" || thread.group.archivedAt) return null;
if (thread.group.kind === "main" && currentSpace) return null;
return { groupId: thread.groupId }; return { groupId: thread.groupId };
} }
return thread.botId ? { botId: thread.botId } : null; return thread.botId ? { botId: thread.botId } : null;
@ -111,25 +93,16 @@ function byCreatedAt(left: InboxItem, right: InboxItem): number {
} }
/** /**
* Turns waiting runs, their ask and takeover messages, open suggestions and unread threads into * Turns waiting runs, their ask and takeover messages, and unread threads into inbox items:
* inbox items:
* everything that waits on the user first, then unread threads not already covered, each * everything that waits on the user first, then unread threads not already covered, each
* oldest first. Items from another space name it. * oldest first.
*/ */
export function buildInboxItems(input: { export function buildInboxItems(input: {
runs: InboxRunRow[]; runs: InboxRunRow[];
/** Ask and takeover messages of those runs, newest first. */ /** Ask and takeover messages of those runs, newest first. */
messages: InboxMessageRow[]; messages: InboxMessageRow[];
unreadThreads: InboxUnreadThreadRow[]; unreadThreads: InboxUnreadThreadRow[];
/** 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[] { }): InboxItem[] {
const spaceOf = (spaceId: string) => {
const space = input.otherSpaces?.get(spaceId);
return space ? { space } : {};
};
const messagesByRun = new Map<string, InboxMessageRow[]>(); const messagesByRun = new Map<string, InboxMessageRow[]>();
for (const message of input.messages) { for (const message of input.messages) {
if (!message.runId) continue; if (!message.runId) continue;
@ -142,14 +115,13 @@ export function buildInboxItems(input: {
for (const run of input.runs) { for (const run of input.runs) {
const messages = messagesByRun.get(run.id) ?? []; const messages = messagesByRun.get(run.id) ?? [];
if (run.status === "waiting_input") { if (run.status === "waiting_input") {
const target = askTarget(run.thread, !input.otherSpaces?.has(run.spaceId)); const target = askTarget(run.thread);
const pending = target ? newest(messages, pendingAsk) : undefined; const pending = target ? newest(messages, pendingAsk) : undefined;
if (!target || !pending) continue; if (!target || !pending) continue;
waiting.push({ waiting.push({
kind: isApprovalAskBlock(pending.block) ? "approval" : "ask", kind: isApprovalAskBlock(pending.block) ? "approval" : "ask",
bot: run.bot, bot: run.bot,
target, target,
...spaceOf(run.spaceId),
runId: run.id, runId: run.id,
messageId: pending.message.id, messageId: pending.message.id,
ask: pending.block, ask: pending.block,
@ -163,7 +135,6 @@ export function buildInboxItems(input: {
kind: "takeover", kind: "takeover",
bot: run.bot, bot: run.bot,
target: { botId: run.bot.id }, target: { botId: run.bot.id },
...spaceOf(run.spaceId),
runId: run.id, runId: run.id,
messageId: request?.message.id ?? null, messageId: request?.message.id ?? null,
text: request?.block.text ?? "", text: request?.block.text ?? "",
@ -172,23 +143,6 @@ 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 covered = new Set(waiting.map((item) => targetKey(item.target)));
const unread: InboxItem[] = []; const unread: InboxItem[] = [];
for (const thread of input.unreadThreads) { for (const thread of input.unreadThreads) {
@ -200,7 +154,6 @@ export function buildInboxItems(input: {
kind: "unread", kind: "unread",
bot: thread.bot, bot: thread.bot,
target, target,
...spaceOf(thread.spaceId),
messageId: latest?.id ?? null, messageId: latest?.id ?? null,
text: previewText(latest?.blocks), text: previewText(latest?.blocks),
createdAt: (latest?.createdAt ?? thread.createdAt).toISOString(), createdAt: (latest?.createdAt ?? thread.createdAt).toISOString(),
@ -212,45 +165,13 @@ export function buildInboxItems(input: {
const botSelect = { id: true, name: true, color: true } as const; const botSelect = { id: true, name: true, color: true } as const;
/** /** The current space's inbox: only the actor's own bots, runs and threads. */
* The other spaces whose items the inbox shows. 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 } } },
});
return new Map(memberships.map(({ space }) => [space.id, space]));
}
/**
* The inbox across the user's spaces: only the actor's own bots, runs and threads. Items are
* answered and opened in their own space, where the thread procedures check membership and
* ownership again.
*/
export async function listInbox(prisma: PrismaClient, actor: Actor): Promise<InboxItem[]> { export async function listInbox(prisma: PrismaClient, actor: Actor): Promise<InboxItem[]> {
const otherSpaces = await otherInboxSpaces(prisma, actor);
const spaceIds = [actor.spaceId, ...otherSpaces.keys()];
const ownBot = { userId: actor.userId, archivedAt: null }; const ownBot = { userId: actor.userId, archivedAt: null };
const threadSelect = { const [runs, unreadThreads] = await Promise.all([
botId: true,
groupId: true,
group: { select: { kind: true, archivedAt: true } },
} as const;
const [runs, unreadThreads, suggestionEffects] = await Promise.all([
prisma.run.findMany({ prisma.run.findMany({
where: { where: {
spaceId: { in: spaceIds }, spaceId: actor.spaceId,
userId: actor.userId, userId: actor.userId,
status: { in: ["waiting_input", "waiting_takeover"] }, status: { in: ["waiting_input", "waiting_takeover"] },
bot: ownBot, bot: ownBot,
@ -258,12 +179,17 @@ export async function listInbox(prisma: PrismaClient, actor: Actor): Promise<Inb
}, },
select: { select: {
id: true, id: true,
spaceId: true,
status: true, status: true,
threadId: true, threadId: true,
updatedAt: true, updatedAt: true,
bot: { select: botSelect }, bot: { select: botSelect },
thread: { select: threadSelect }, thread: {
select: {
botId: true,
groupId: true,
group: { select: { kind: true, archivedAt: true } },
},
},
}, },
orderBy: [{ updatedAt: "asc" }, { id: "asc" }], orderBy: [{ updatedAt: "asc" }, { id: "asc" }],
take: INBOX_RUN_LIMIT, take: INBOX_RUN_LIMIT,
@ -271,13 +197,12 @@ export async function listInbox(prisma: PrismaClient, actor: Actor): Promise<Inb
// Only specialists' own threads; group threads, the main conversation included, stay out. // Only specialists' own threads; group threads, the main conversation included, stay out.
prisma.thread.findMany({ prisma.thread.findMany({
where: { where: {
spaceId: { in: spaceIds }, spaceId: actor.spaceId,
userId: actor.userId, userId: actor.userId,
unread: true, unread: true,
bot: { ...ownBot, kind: "specialist" }, bot: { ...ownBot, kind: "specialist" },
}, },
select: { select: {
spaceId: true,
createdAt: true, createdAt: true,
bot: { select: botSelect }, bot: { select: botSelect },
messages: { messages: {
@ -290,54 +215,7 @@ export async function listInbox(prisma: PrismaClient, actor: Actor): Promise<Inb
orderBy: [{ createdAt: "asc" }, { id: "asc" }], orderBy: [{ createdAt: "asc" }, { id: "asc" }],
take: INBOX_UNREAD_LIMIT, 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 = const messages =
runs.length === 0 runs.length === 0
? [] ? []
@ -354,5 +232,5 @@ export async function listInbox(prisma: PrismaClient, actor: Actor): Promise<Inb
orderBy: { seq: "desc" }, orderBy: { seq: "desc" },
select: { id: true, runId: true, blocks: true, createdAt: true }, select: { id: true, runId: true, blocks: true, createdAt: true },
}); });
return buildInboxItems({ runs, messages, unreadThreads, suggestions, otherSpaces }); return buildInboxItems({ runs, messages, unreadThreads });
} }

View File

@ -7,7 +7,6 @@ import { serve } from "@hono/node-server";
import { SERVICE_NAMES } from "@rakazo/logging"; import { SERVICE_NAMES } from "@rakazo/logging";
import { createRootLogger } from "@rakazo/logging/axiom"; import { createRootLogger } from "@rakazo/logging/axiom";
import { createApp } from "./app.js"; import { createApp } from "./app.js";
import { API_SERVER_OPTIONS } from "./artifact-http.js";
import { loadEnv } from "./env.js"; import { loadEnv } from "./env.js";
const logger = createRootLogger(SERVICE_NAMES.api); const logger = createRootLogger(SERVICE_NAMES.api);
@ -15,17 +14,9 @@ const logger = createRootLogger(SERVICE_NAMES.api);
try { try {
const env = loadEnv(); const env = loadEnv();
const { app, stop } = await createApp({ ...env, logger }); const { app, stop } = await createApp({ ...env, logger });
const server = serve( const server = serve({ fetch: app.fetch, port: env.port, hostname: env.apiHost }, () => {
{ logger.info("api listening", { "http.host": env.apiHost, "http.port": env.port });
fetch: app.fetch, });
port: env.port,
hostname: env.apiHost,
serverOptions: API_SERVER_OPTIONS,
},
() => {
logger.info("api listening", { "http.host": env.apiHost, "http.port": env.port });
},
);
// Long-lived connections (threads.subscribe SSE streams) never end on their // Long-lived connections (threads.subscribe SSE streams) never end on their
// own, so server.close() alone waits forever for them. Track sockets and // own, so server.close() alone waits forever for them. Track sockets and

View File

@ -1,4 +1,4 @@
import { ORPCError, ValidationError } from "@orpc/server"; import { ORPCError } from "@orpc/server";
import { createLogger, createTestSink, installLogger } from "@rakazo/logging"; import { createLogger, createTestSink, installLogger } from "@rakazo/logging";
import { afterEach, describe, expect, it } from "vitest"; import { afterEach, describe, expect, it } from "vitest";
import { logUnexpectedRpcError } from "./app.js"; import { logUnexpectedRpcError } from "./app.js";
@ -20,28 +20,6 @@ describe("logUnexpectedRpcError", () => {
expect(sink.events).toEqual([]); 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", () => { it("names the procedure and every cause behind an opaque failure", () => {
const sink = createTestSink(); const sink = createTestSink();
installLogger(createLogger({ service: "rakazo-api", sinks: [sink] })); installLogger(createLogger({ service: "rakazo-api", sinks: [sink] }));

View File

@ -1,35 +0,0 @@
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";
const actor: Actor = {
userId: "user-1",
spaceId: "space-1",
email: "user@rakazo.test",
isDeploymentOwner: false,
};
describe("media capabilities", () => {
it("answers for the actor's space", async () => {
const asked: string[] = [];
const deps = {
media: {
capabilities: async (spaceId: string) => {
asked.push(spaceId);
return { transcribe: true, export_video: false } as MediaCapabilities;
},
},
} as unknown as MediaRouteDeps;
await expect(getMediaCapabilities(deps, actor)).resolves.toMatchObject({ transcribe: true });
expect(asked).toEqual(["space-1"]);
});
it("reports nothing available without a media runtime", async () => {
const capabilities = await getMediaCapabilities({} as MediaRouteDeps, actor);
expect(Object.keys(capabilities)).toEqual(MediaOperationSchema.options);
expect(Object.values(capabilities).every((value) => value === false)).toBe(true);
});
});

265
apps/api/src/media-http.ts Normal file
View File

@ -0,0 +1,265 @@
import { createReadStream } from "node:fs";
import { mkdir, stat } from "node:fs/promises";
import path from "node:path";
import { Readable } from "node:stream";
import type { MediaServices } from "@rakazo/adapters";
import type { Actor } from "@rakazo/contracts";
import { IsolationError, MediaStateConflictError } from "@rakazo/db";
import { assertInside, PathOutsideRootError, resolveInside } from "@rakazo/media/node";
import type { Context, Hono } from "hono";
import { cancelBody } from "./http-body.js";
import { MEDIA_CHUNK_TIMEOUT_MS, UploadChunkError, writeUploadChunk } from "./media-uploads.js";
/*
* Byte routes Media Studio cannot express as JSON RPC: chunk uploads and
* streaming of proxies, thumbnails, renders, and bundles. Every id is checked
* against the actor's space before a path is derived, and every path is
* resolved inside the media root.
*/
const ID = /^[A-Za-z0-9_-]{1,64}$/;
const FILE_NAME = /^[A-Za-z0-9][A-Za-z0-9._-]{0,199}$/;
const CHUNK_INDEX = /^[0-9]{1,7}$/;
const CONTENT_TYPES: Record<string, string> = {
".mp4": "video/mp4",
".m4v": "video/mp4",
".mov": "video/quicktime",
".webm": "video/webm",
".jpg": "image/jpeg",
".jpeg": "image/jpeg",
".png": "image/png",
".webp": "image/webp",
".gif": "image/gif",
".srt": "application/x-subrip",
".vtt": "text/vtt; charset=utf-8",
".json": "application/json",
".md": "text/markdown; charset=utf-8",
".txt": "text/plain; charset=utf-8",
".zip": "application/zip",
".m4a": "audio/mp4",
".wav": "audio/wav",
};
export function mediaContentType(fileName: string): string {
return CONTENT_TYPES[path.extname(fileName).toLowerCase()] ?? "application/octet-stream";
}
export type ByteRange = { start: number; end: number };
/**
* Parse a single `bytes=` range. Returns null for no (or an ignorable
* multi-part) range and "unsatisfiable" when it cannot be served.
*/
export function parseByteRange(
header: string | undefined,
size: number,
): ByteRange | null | "unsatisfiable" {
if (!header) return null;
const match = /^bytes=(\d*)-(\d*)$/.exec(header.trim());
if (!match) return header.includes(",") ? null : "unsatisfiable";
const [, first = "", last = ""] = match;
if (first === "" && last === "") return "unsatisfiable";
if (first === "") {
const suffix = Number(last);
if (suffix === 0 || size === 0) return "unsatisfiable";
return { start: Math.max(0, size - suffix), end: size - 1 };
}
const start = Number(first);
const end = last === "" ? size - 1 : Math.min(Number(last), size - 1);
if (start >= size || end < start) return "unsatisfiable";
return { start, end };
}
async function sendFile(
c: Context,
absolutePath: string,
options: { fileName: string; download?: boolean },
): Promise<Response> {
const info = await stat(absolutePath).catch(() => null);
if (!info?.isFile()) return c.json({ error: "Not found" }, 404);
const size = info.size;
const headers: Record<string, string> = {
"content-type": mediaContentType(options.fileName),
"accept-ranges": "bytes",
"cache-control": "private, max-age=300",
};
if (options.download) {
headers["content-disposition"] = `attachment; filename="${options.fileName}"`;
}
const range = parseByteRange(c.req.header("range"), size);
if (range === "unsatisfiable") {
return new Response(null, {
status: 416,
headers: { ...headers, "content-range": `bytes */${size}` },
});
}
if (c.req.method === "HEAD") {
return new Response(null, {
status: 200,
headers: { ...headers, "content-length": String(size) },
});
}
const start = range?.start ?? 0;
const end = range?.end ?? size - 1;
const body =
size === 0
? null
: (Readable.toWeb(
createReadStream(absolutePath, { start, end }),
) as ReadableStream<Uint8Array>);
return new Response(body, {
status: range ? 206 : 200,
headers: {
...headers,
"content-length": String(size === 0 ? 0 : end - start + 1),
...(range ? { "content-range": `bytes ${start}-${end}/${size}` } : {}),
},
});
}
function mediaHttpError(c: Context, error: unknown): Response {
if (error instanceof IsolationError || error instanceof PathOutsideRootError) {
return c.json({ error: "Not found" }, 404);
}
if (error instanceof UploadChunkError) return c.json({ error: error.message }, error.status);
if (error instanceof MediaStateConflictError) return c.json({ error: error.message }, 409);
if ((error as NodeJS.ErrnoException | null)?.code === "ENOENT") {
return c.json({ error: "Not found" }, 404);
}
throw error;
}
export function mountMediaHttpRoutes(
app: Hono,
deps: {
/** Null when Media Studio is not configured: every route answers 404. */
services: MediaServices | null;
chunkTimeoutMs?: number;
},
resolveActor: (c: Context) => Promise<Actor | null>,
) {
async function authorize(
c: Context,
): Promise<
{ actor: Actor; services: MediaServices; response?: undefined } | { response: Response }
> {
const actor = await resolveActor(c);
if (!actor) return { response: c.json({ error: "Unauthorized" }, 401) };
if (!deps.services)
return { response: c.json({ error: "Media Studio is not configured" }, 404) };
return { actor, services: deps.services };
}
async function mediaFile(services: MediaServices, relative: string | null) {
if (!relative) return null;
await mkdir(services.deps.mediaRoot, { recursive: true });
return resolveInside(services.deps.mediaRoot, relative);
}
app.put("/api/media/uploads/:uploadId/chunks/:index", async (c) => {
const auth = await authorize(c);
if (auth.response) {
cancelBody(c.req.raw.body);
return auth.response;
}
const uploadId = c.req.param("uploadId");
const index = c.req.param("index");
if (!ID.test(uploadId) || !CHUNK_INDEX.test(index)) {
cancelBody(c.req.raw.body);
return c.json({ error: "Not found" }, 404);
}
const lengthHeader = c.req.header("content-length");
const declared = lengthHeader && /^[0-9]+$/.test(lengthHeader) ? Number(lengthHeader) : null;
const timeout = AbortSignal.timeout(deps.chunkTimeoutMs ?? MEDIA_CHUNK_TIMEOUT_MS);
const body = c.req.raw.body;
try {
const upload = await writeUploadChunk({
services: auth.services,
spaceId: auth.actor.spaceId,
uploadId,
index: Number(index),
body,
declaredLength: declared,
signal: AbortSignal.any([timeout, c.req.raw.signal]),
});
return c.json(upload);
} catch (error) {
cancelBody(body);
if (timeout.aborted) return c.json({ error: "Chunk took too long" }, 408);
return mediaHttpError(c, error);
}
});
app.get("/api/media/renders/:renderId/files/:fileName", async (c) => {
const auth = await authorize(c);
if (auth.response) return auth.response;
const renderId = c.req.param("renderId");
const fileName = c.req.param("fileName");
if (!ID.test(renderId) || !FILE_NAME.test(fileName) || fileName.includes("..")) {
return c.json({ error: "Not found" }, 404);
}
try {
const render = await auth.services.deps.repos.getRenderRecord(auth.actor.spaceId, renderId);
const download = c.req.query("download") === "1";
// Only a render that passed its gate leaves the app as a download.
if (download && render.status !== "done") return c.json({ error: "Not found" }, 404);
const absolute = await auth.services.render.outputPath(auth.actor, renderId, fileName);
if (!absolute) return c.json({ error: "Not found" }, 404);
await mkdir(auth.services.deps.mediaRoot, { recursive: true });
const inside = await assertInside(
await resolveInside(
auth.services.deps.mediaRoot,
`${auth.actor.spaceId}/renders/${renderId}`,
),
absolute,
);
return await sendFile(c, inside, { fileName, download });
} catch (error) {
return mediaHttpError(c, error);
}
});
for (const kind of ["thumb", "proxy"] as const) {
app.get(`/api/media/assets/:assetId/${kind}`, async (c) => {
const auth = await authorize(c);
if (auth.response) return auth.response;
const assetId = c.req.param("assetId");
if (!ID.test(assetId)) return c.json({ error: "Not found" }, 404);
try {
const asset = await auth.services.deps.repos.getAssetRecord(auth.actor.spaceId, assetId);
const relative = kind === "thumb" ? asset.thumbPath : asset.proxyPath;
const absolute = await mediaFile(auth.services, relative);
if (!absolute) return c.json({ error: "Not found" }, 404);
return await sendFile(c, absolute, { fileName: path.basename(absolute) });
} catch (error) {
return mediaHttpError(c, error);
}
});
}
app.get("/api/media/bundles/:publicationId", async (c) => {
const auth = await authorize(c);
if (auth.response) return auth.response;
const publicationId = c.req.param("publicationId");
if (!ID.test(publicationId)) return c.json({ error: "Not found" }, 404);
try {
const publication = await auth.services.deps.repos.getPublicationRecord(
auth.actor.spaceId,
publicationId,
);
if (publication.bundleArtifactId) return c.json({ error: "Not found" }, 404);
const absolute = await mediaFile(
auth.services,
`${auth.actor.spaceId}/bundles/${publication.id}/bundle.zip`,
);
if (!absolute) return c.json({ error: "Not found" }, 404);
return await sendFile(c, absolute, {
fileName: `bundle-${publication.id}.zip`,
download: true,
});
} catch (error) {
return mediaHttpError(c, error);
}
});
}

View File

@ -1,165 +0,0 @@
import { ORPCError } from "@orpc/server";
import type { MediaCapabilities, MediaRuntime } from "@rakazo/adapter-kit";
import type { EncryptedSecretStore } from "@rakazo/adapters";
import {
loadMediaSettings,
MediaJobRejectedError,
MediaSettingsInvalidError,
mediaSettingsView,
saveMediaSettings,
} from "@rakazo/adapters";
import type { Actor, MediaJobStartInput, MediaSettingsUpdate } from "@rakazo/contracts";
import { MediaOperationSchema } from "@rakazo/contracts";
import type { PrismaClient } from "@rakazo/db";
import { IsolationError, requireMembership } from "@rakazo/db";
import { resolveThreadTarget } from "./thread-target.js";
export interface MediaRouteDeps {
prisma: PrismaClient;
secrets: EncryptedSecretStore;
media?: MediaRuntime;
}
/** Media backends run on the deployment owner's own server, so only they configure them. */
function requireDeploymentOwner(actor: Actor) {
if (!actor.isDeploymentOwner) throw new ORPCError("FORBIDDEN");
}
function requireMedia(deps: MediaRouteDeps): MediaRuntime {
if (!deps.media) throw new ORPCError("NOT_IMPLEMENTED");
return deps.media;
}
export async function getMediaSettings(deps: MediaRouteDeps, actor: Actor) {
requireDeploymentOwner(actor);
return mediaSettingsView(await loadMediaSettings(deps));
}
export async function updateMediaSettings(
deps: MediaRouteDeps,
actor: Actor,
input: MediaSettingsUpdate,
) {
requireDeploymentOwner(actor);
try {
return await saveMediaSettings(deps, input);
} catch (error) {
if (error instanceof MediaSettingsInvalidError) {
throw new ORPCError("BAD_REQUEST", { message: error.message });
}
throw error;
}
}
/**
* The actor in the space a card lives in. A card is acted on where it is shown, which need not be
* the space the client has selected (the main conversation lives in the home space), so the
* space comes from the card's own record: it must belong to the actor, and the actor must still
* be a member of that space. Anything else reads as not found.
*/
async function actorInSpace(deps: MediaRouteDeps, actor: Actor, spaceId: string): Promise<Actor> {
if (spaceId === actor.spaceId) return actor;
try {
return await requireMembership(deps.prisma, actor.userId, spaceId);
} catch (error) {
if (error instanceof IsolationError) throw new ORPCError("NOT_FOUND");
throw error;
}
}
/**
* Starts an operation a user picked on a card (export, transcribe, edit) in the card's
* conversation. The conversation is authorized like every thread procedure, in the card's space;
* the runtime checks that the operation's source artifacts belong to the actor there, and the
* job can only ever read the actor's artifacts of that space.
*/
export async function startMediaJob(deps: MediaRouteDeps, actor: Actor, input: MediaJobStartInput) {
const media = requireMedia(deps);
const source = await deps.prisma.message.findFirst({
where: {
id: input.messageId,
thread: {
userId: actor.userId,
...(input.botId ? { botId: input.botId } : { groupId: input.groupId }),
},
},
select: { botId: true, thread: { select: { spaceId: true } } },
});
if (!source) throw new ORPCError("NOT_FOUND");
const scoped = await actorInSpace(deps, actor, source.thread.spaceId);
const target = await resolveThreadTarget(deps.prisma, scoped, input).catch((error) => {
if (error instanceof IsolationError) throw new ORPCError("NOT_FOUND");
throw error;
});
const bots =
target.kind === "bot"
? [target.botId]
: [
...(target.leadBotId && target.memberBotIds.includes(target.leadBotId)
? [target.leadBotId]
: []),
...target.memberBotIds,
];
const botId = source.botId && bots.includes(source.botId) ? source.botId : bots[0];
if (!botId) throw new ORPCError("NOT_FOUND");
try {
return await media.start({
operation: input.operation,
input: input.input,
spaceId: scoped.spaceId,
userId: scoped.userId,
botId,
groupId: target.kind === "group" ? target.groupId : null,
threadId: target.threadId,
runId: null,
});
} catch (error) {
if (error instanceof MediaJobRejectedError) {
throw new ORPCError("BAD_REQUEST", { message: error.message });
}
throw error;
}
}
/**
* Booleans only: which operations a space can run, never how they are configured. With a job,
* the space is the job's (a card's follow-ups); otherwise the actor's current space.
*/
export async function getMediaCapabilities(
deps: MediaRouteDeps,
actor: Actor,
input: { jobId?: string } = {},
): Promise<MediaCapabilities> {
const spaceId = input.jobId ? (await ownedJob(deps, actor, input.jobId)).spaceId : actor.spaceId;
if (deps.media) return deps.media.capabilities(spaceId);
return Object.fromEntries(
MediaOperationSchema.options.map((operation) => [operation, false]),
) as MediaCapabilities;
}
/** The actor's job, in whichever of the actor's spaces it runs. */
async function ownedJob(deps: MediaRouteDeps, actor: Actor, jobId: string) {
const job = await deps.prisma.mediaJob.findFirst({
where: { id: jobId, userId: actor.userId },
select: { id: true, spaceId: true },
});
if (!job) throw new ORPCError("NOT_FOUND");
await actorInSpace(deps, actor, job.spaceId);
return job;
}
export async function getMediaJob(deps: MediaRouteDeps, actor: Actor, jobId: string) {
const media = requireMedia(deps);
const snapshot = await media.get((await ownedJob(deps, actor, jobId)).id);
if (!snapshot) throw new ORPCError("NOT_FOUND");
return snapshot;
}
export async function cancelMediaJob(deps: MediaRouteDeps, actor: Actor, jobId: string) {
const media = requireMedia(deps);
const { id } = await ownedJob(deps, actor, jobId);
await media.cancel(id);
const snapshot = await media.get(id);
if (!snapshot) throw new ORPCError("NOT_FOUND");
return snapshot;
}

View File

@ -1,82 +0,0 @@
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);
}

View File

@ -0,0 +1,389 @@
import type { MediaInferenceProvider } from "@rakazo/adapter-kit";
import type {
MediaActor,
MediaIngestService,
MediaMarketingService,
MediaPrivacyService,
MediaRenderService,
MediaServiceDeps,
MediaServices,
} from "@rakazo/adapters";
import type { PlatformProfile } from "@rakazo/contracts";
import { mediaRpcShapes } from "@rakazo/contracts";
import type { MediaRepos, PrismaClient } from "@rakazo/db";
/*
* In-memory stand-ins for the media service slices, for API tests only. They
* record what the router hands them and do the minimum through the real
* repositories; nothing here renders, detects, or publishes.
*/
export interface MediaServiceCall {
service: "ingest" | "privacy" | "render" | "marketing";
method: string;
actor: MediaActor;
input: unknown;
}
export interface FakeMediaServicesOptions {
repos: MediaRepos;
mediaRoot: string;
prisma?: PrismaClient;
inference?: MediaInferenceProvider | null;
profiles?: PlatformProfile[];
now?: () => Date;
/** Render id → absolute output path the fake render service reports. */
renderOutputs?: Map<string, string>;
onUploadCompleted?: (
actor: MediaActor,
input: { assetId: string; tempPath: string; sha256: string; fileName: string },
) => Promise<void>;
}
export function fakeMediaServices(options: FakeMediaServicesOptions): {
services: MediaServices;
calls: MediaServiceCall[];
} {
const calls: MediaServiceCall[] = [];
const { repos } = options;
const record = (
service: MediaServiceCall["service"],
method: string,
actor: MediaActor,
input: unknown,
) => {
calls.push({ service, method, actor: { spaceId: actor.spaceId, userId: actor.userId }, input });
};
const ingest: MediaIngestService = {
async pushItems(actor, input) {
record("ingest", "pushItems", actor, input);
return { created: 0, updated: 0, unchanged: 0, deleted: 0 };
},
async requestSync(actor, sourceId) {
record("ingest", "requestSync", actor, sourceId);
},
async uploadCompleted(actor, input) {
record("ingest", "uploadCompleted", actor, input);
await options.onUploadCompleted?.(actor, input);
},
async recluster(actor) {
record("ingest", "recluster", actor, null);
},
async removeSource(actor, sourceId) {
record("ingest", "removeSource", actor, sourceId);
await repos.removeSource(actor.spaceId, sourceId);
},
};
const privacy: MediaPrivacyService = {
async prepareSources() {
throw new Error("prepareSources is not part of the API fakes");
},
async gate() {
throw new Error("gate is not part of the API fakes");
},
async createMark(actor, input) {
record("privacy", "createMark", actor, input);
return repos.createMark(actor.spaceId, input);
},
async updateMark(actor, input) {
record("privacy", "updateMark", actor, input);
const { markId, ...patch } = input;
return repos.updateMark(actor.spaceId, markId, patch);
},
async removePerson(actor, personId) {
record("privacy", "removePerson", actor, personId);
await repos.removePersonCompletely(actor.spaceId, personId);
},
};
const render: MediaRenderService = {
async start(actor, input) {
record("render", "start", actor, input);
return repos.createRender(actor.spaceId, { ...input, createdBy: actor.userId });
},
async cancel(actor, renderId) {
record("render", "cancel", actor, renderId);
return repos.cancelRender(actor.spaceId, renderId);
},
async outputPath(actor, renderId, fileName) {
record("render", "outputPath", actor, { renderId, fileName });
const file = options.renderOutputs?.get(renderId);
return file?.endsWith(`/${fileName}`) ? file : null;
},
};
const marketing: MediaMarketingService = {
async autoPlan(actor, input) {
record("marketing", "autoPlan", actor, input);
return [];
},
async submitPlan(actor, input) {
record("marketing", "submitPlan", actor, input);
return { ok: false, errors: [] };
},
async introVariants(actor, input) {
record("marketing", "introVariants", actor, input);
return [];
},
async thumbnails(actor, input) {
record("marketing", "thumbnails", actor, input);
return [];
},
async copy(actor, input) {
record("marketing", "copy", actor, input);
const render = await repos.getRender(actor.spaceId, input.renderId);
return {
profileId: render.profileId,
language: input.language,
title: "Title",
hook: "Hook",
caption: "Caption",
hashtags: [],
issues: [],
};
},
async createPublication(actor, input) {
record("marketing", "createPublication", actor, input);
return repos.createPublication(actor.spaceId, actor.userId, {
...input,
trackingUrl: null,
scheduledAt: input.scheduledAt ? new Date(input.scheduledAt) : null,
});
},
async bundle(actor, publicationId) {
record("marketing", "bundle", actor, publicationId);
return repos.markPublicationBundled(actor.spaceId, publicationId, null);
},
async cancelPublication(actor, publicationId) {
record("marketing", "cancelPublication", actor, publicationId);
return repos.cancelPublication(actor.spaceId, publicationId);
},
async importMetricsCsv(actor, input) {
record("marketing", "importMetricsCsv", actor, input);
return { imported: 0, skipped: [] };
},
};
const deps = {
prisma: options.prisma,
repos,
jobs: { publish: async () => undefined },
inference: options.inference ?? null,
renderer: {
id: "fake",
hardwareEncoders: async () => ["h264_fake"],
render: async () => {
throw new Error("render is not part of the API fakes");
},
},
motion: { id: "fake", templates: () => [], render: async () => ({ path: "", hasAlpha: true }) },
publisher: { id: "fake", publish: async () => ({ status: "bundled", externalUrl: null }) },
shortener: null,
sources: {},
mediaRoot: options.mediaRoot,
profiles: async () => ({
profiles: new Map(
(options.profiles ?? []).map((profile) => [
profile.id,
{ profile, origin: "builtin" as const },
]),
),
errors: [],
}),
artifacts: {},
model: { generate: async () => null },
publish: async () => undefined,
now: options.now ?? (() => new Date()),
} as unknown as MediaServiceDeps;
return { services: { deps, ingest, privacy, render, marketing }, calls };
}
/** Every RPC path of the media contract, e.g. "sources/list". */
export function mediaProcedurePaths(): string[] {
const paths: string[] = [];
const walk = (node: Record<string, unknown>, prefix: string[]) => {
for (const [key, value] of Object.entries(node)) {
const child = value as Record<string, unknown>;
if ("input" in child && "output" in child) paths.push([...prefix, key].join("/"));
else walk(child, [...prefix, key]);
}
};
walk(mediaRpcShapes, []);
return paths;
}
export interface MediaSampleIds {
sourceId: string;
assetId: string;
secondAssetId: string;
segmentId: string;
trackId: string;
personId: string;
markId: string;
collectionId: string;
brandKitId: string;
projectId: string;
planId: string;
renderId: string;
publicationId: string;
mappingId: string;
uploadId: string;
}
/**
* How a procedure must treat another space's ids: refuse them as not found,
* answer only from the caller's own space, or be covered by a dedicated test.
*/
export type ForeignExpectation = "not_found" | "scoped" | "dedicated";
/** A valid input for every media procedure, built from one space's ids. */
export function sampleMediaInputs(
ids: MediaSampleIds,
kit: unknown,
): Record<string, { input: unknown; foreign: ForeignExpectation }> {
const at = "2026-09-01T10:00:00.000Z";
return {
capabilities: { input: {}, foreign: "scoped" },
"sources/list": { input: {}, foreign: "scoped" },
"sources/createFolder": { input: { label: "Footage", path: "/" }, foreign: "dedicated" },
"sources/registerClient": {
input: { kind: "ios_library", label: "Phone", deviceId: "device-sample" },
foreign: "scoped",
},
"sources/pushItems": {
input: { sourceId: ids.sourceId, items: [], syncToken: null, complete: true },
foreign: "not_found",
},
"sources/sync": { input: { sourceId: ids.sourceId }, foreign: "not_found" },
"sources/remove": { input: { sourceId: ids.sourceId }, foreign: "not_found" },
"assets/search": { input: {}, foreign: "scoped" },
"assets/get": { input: { assetId: ids.assetId }, foreign: "not_found" },
"assets/setPaidOnly": {
input: { segmentIds: [ids.segmentId], paidOnly: true },
foreign: "not_found",
},
"assets/requestUpload": { input: { assetIds: [ids.assetId] }, foreign: "not_found" },
"uploads/begin": {
input: {
assetId: ids.secondAssetId,
sizeBytes: 10,
sha256: "a".repeat(64),
fileName: "clip.mp4",
},
foreign: "not_found",
},
"uploads/status": { input: { uploadId: ids.uploadId }, foreign: "not_found" },
"uploads/complete": { input: { uploadId: ids.uploadId }, foreign: "not_found" },
"collections/list": { input: {}, foreign: "scoped" },
"collections/get": { input: { collectionId: ids.collectionId }, foreign: "not_found" },
"collections/rename": {
input: { collectionId: ids.collectionId, title: "Renamed" },
foreign: "not_found",
},
"collections/merge": {
input: { collectionIds: [ids.collectionId, `${ids.collectionId}x`] },
foreign: "not_found",
},
"collections/split": {
input: { collectionId: ids.collectionId, atAssetId: ids.assetId },
foreign: "not_found",
},
"collections/createManual": {
input: { title: "Picks", assetIds: [ids.assetId] },
foreign: "not_found",
},
"collections/recluster": { input: {}, foreign: "scoped" },
"people/list": { input: {}, foreign: "scoped" },
"people/update": { input: { personId: ids.personId, displayName: "X" }, foreign: "not_found" },
"people/merge": {
input: { personIds: [ids.personId, `${ids.personId}x`] },
foreign: "not_found",
},
"people/assignTrack": { input: { trackId: ids.trackId, personId: null }, foreign: "not_found" },
"people/recordConsent": {
input: {
personId: ids.personId,
consentArtifactId: null,
consentRecordedAt: at,
ageVerificationMethod: "platform_verified",
ageVerifiedAt: at,
},
foreign: "not_found",
},
"people/remove": { input: { personId: ids.personId }, foreign: "not_found" },
"marks/list": { input: { assetId: ids.assetId }, foreign: "scoped" },
"marks/create": {
input: { kind: "tattoo", assetId: ids.assetId, prompt: { tMs: 0 }, treatment: "blur" },
foreign: "not_found",
},
"marks/update": { input: { markId: ids.markId, label: "x" }, foreign: "not_found" },
"marks/remove": { input: { markId: ids.markId }, foreign: "not_found" },
"brandKits/list": { input: {}, foreign: "scoped" },
"brandKits/upsert": { input: { id: ids.brandKitId, kit }, foreign: "not_found" },
"brandKits/remove": { input: { id: ids.brandKitId }, foreign: "not_found" },
"brandKits/templates": { input: {}, foreign: "scoped" },
"brandKits/introVariants": { input: { id: ids.brandKitId }, foreign: "not_found" },
"profiles/list": { input: {}, foreign: "scoped" },
"profiles/get": { input: { profileId: "example-short-vertical" }, foreign: "scoped" },
"projects/list": { input: {}, foreign: "scoped" },
"projects/get": { input: { projectId: ids.projectId }, foreign: "not_found" },
"projects/create": {
input: {
title: "Teaser",
goal: "teaser",
collectionIds: [ids.collectionId],
profileIds: ["example-short-vertical"],
},
foreign: "not_found",
},
"projects/update": { input: { projectId: ids.projectId, title: "X" }, foreign: "not_found" },
"projects/remove": { input: { projectId: ids.projectId }, foreign: "not_found" },
"projects/autoPlan": { input: { projectId: ids.projectId }, foreign: "not_found" },
"plans/submit": { input: { projectId: ids.projectId, edl: {} }, foreign: "not_found" },
"renders/start": { input: { planId: ids.planId, quality: "draft" }, foreign: "not_found" },
"renders/get": { input: { renderId: ids.renderId }, foreign: "not_found" },
"renders/cancel": { input: { renderId: ids.renderId }, foreign: "not_found" },
"renders/thumbnails": { input: { renderId: ids.renderId }, foreign: "not_found" },
"marketing/copy": { input: { renderId: ids.renderId }, foreign: "not_found" },
"publications/list": { input: { projectId: ids.projectId }, foreign: "scoped" },
"publications/create": { input: { renderId: ids.renderId }, foreign: "not_found" },
"publications/bundle": { input: { publicationId: ids.publicationId }, foreign: "not_found" },
"publications/markPublished": {
input: { publicationId: ids.publicationId, externalUrl: null },
foreign: "not_found",
},
"publications/cancel": { input: { publicationId: ids.publicationId }, foreign: "not_found" },
"metrics/mappings": { input: {}, foreign: "scoped" },
"metrics/saveMapping": {
input: {
id: ids.mappingId,
name: "Imported",
columns: { publication: "id", observedAt: "date" },
publicationKey: "id",
},
foreign: "not_found",
},
"metrics/importCsv": {
input: { mappingId: ids.mappingId, csv: "id,date" },
foreign: "not_found",
},
"metrics/record": {
input: {
publicationId: ids.publicationId,
observedAt: at,
views: 1,
clicks: 0,
conversions: 0,
revenueCents: 0,
currency: "EUR",
},
foreign: "not_found",
},
"metrics/funnel": { input: { projectId: ids.projectId }, foreign: "not_found" },
"metrics/segments": { input: { projectId: ids.projectId }, foreign: "not_found" },
"metrics/resetTeaserModel": { input: {}, foreign: "scoped" },
};
}

View File

@ -0,0 +1,244 @@
import { createHash, randomUUID } from "node:crypto";
import { createReadStream } from "node:fs";
import { mkdir, open, rename, rm, stat } from "node:fs/promises";
import path from "node:path";
import type { MediaActor, MediaServices } from "@rakazo/adapters";
import type { MediaUpload } from "@rakazo/contracts";
import type { MediaRepos } from "@rakazo/db";
import { MediaStateConflictError } from "@rakazo/db";
import { resolveInside } from "@rakazo/media/node";
/*
* Resumable chunked uploads. Each chunk lands in its own file
* (<spaceId>/uploads/<uploadId>/chunks/<index>.part, written to a temp name and
* renamed, so a repeated or concurrent PUT of the same index is harmless).
* Completion concatenates the chunks in order into <spaceId>/uploads/<uploadId>/file
* while hashing, verifies size and SHA-256, and hands the file to ingest.
*
* Limits: the contract caps the total size, every chunk must have its exact
* length, one chunk must arrive within MEDIA_CHUNK_TIMEOUT_MS, and an upload
* that saw no progress for MEDIA_UPLOAD_IDLE_MS expires and must start over.
*/
/** A single chunk must arrive within this window; slow links resume per chunk. */
export const MEDIA_CHUNK_TIMEOUT_MS = 10 * 60_000;
/** An open upload without a new chunk for this long is failed and its bytes removed. */
export const MEDIA_UPLOAD_IDLE_MS = 24 * 60 * 60_000;
export class UploadChunkError extends Error {
constructor(
readonly status: 400 | 409 | 413,
message: string,
) {
super(message);
this.name = "UploadChunkError";
}
}
type UploadRow = Awaited<ReturnType<MediaRepos["getUploadRecord"]>>;
type UploadSize = Pick<UploadRow, "sizeBytes" | "chunkBytes">;
export function uploadChunkCount(row: UploadSize): number {
return Number((row.sizeBytes + BigInt(row.chunkBytes) - 1n) / BigInt(row.chunkBytes));
}
/** Exact byte length chunk `index` must have: every chunk is full except the last. */
export function expectedChunkBytes(row: UploadSize, index: number): number {
const count = uploadChunkCount(row);
if (index < count - 1) return row.chunkBytes;
return Number(row.sizeBytes - BigInt(row.chunkBytes) * BigInt(count - 1));
}
function uploadDir(row: Pick<UploadRow, "spaceId" | "id">): string {
return `${row.spaceId}/uploads/${row.id}`;
}
async function mediaPath(mediaRoot: string, relative: string): Promise<string> {
await mkdir(mediaRoot, { recursive: true });
return resolveInside(mediaRoot, relative);
}
async function chunksDir(mediaRoot: string, row: UploadRow): Promise<string> {
const dir = await mediaPath(mediaRoot, `${uploadDir(row)}/chunks`);
await mkdir(dir, { recursive: true });
return dir;
}
async function removeUploadFiles(mediaRoot: string, row: UploadRow): Promise<void> {
await rm(await mediaPath(mediaRoot, uploadDir(row)), { recursive: true, force: true });
}
function isIdle(services: MediaServices, row: UploadRow): boolean {
return services.deps.now().getTime() - row.updatedAt.getTime() > MEDIA_UPLOAD_IDLE_MS;
}
/** Fail an idle open upload and drop its bytes. Returns true when it expired. */
async function expireIfIdle(services: MediaServices, row: UploadRow): Promise<boolean> {
if (row.status !== "open" || !isIdle(services, row)) return false;
await services.deps.repos.failUpload(row.spaceId, row.id).catch((error: unknown) => {
// A concurrent request already closed it; the files still go.
if (!(error instanceof MediaStateConflictError)) throw error;
});
await removeUploadFiles(services.deps.mediaRoot, row);
return true;
}
/**
* Open an upload, or resume the open one for the same asset and content. An
* idle one expires first so the client starts over with a fresh upload.
*/
export async function beginMediaUpload(
services: MediaServices,
actor: MediaActor,
input: { assetId: string; sizeBytes: number; sha256: string; fileName: string },
): Promise<MediaUpload> {
const { repos } = services.deps;
const asset = await repos.getAssetRecord(actor.spaceId, input.assetId);
// Device items wait for their original; files read from a source never upload.
if (asset.storage !== "remote" && asset.ingestState !== "awaiting_upload") {
throw new MediaStateConflictError("This item does not need an upload");
}
const begin = () =>
repos.beginUpload(actor.spaceId, { ...input, assetId: asset.id, userId: actor.userId });
const upload = await begin();
const row = await repos.getUploadRecord(actor.spaceId, upload.id);
return (await expireIfIdle(services, row)) ? begin() : upload;
}
/**
* Stream one chunk body to disk without buffering more than one network read.
* Rejects bodies longer or shorter than the chunk's exact size.
*/
export async function writeUploadChunk(input: {
services: MediaServices;
spaceId: string;
uploadId: string;
index: number;
body: ReadableStream<Uint8Array> | null;
declaredLength: number | null;
/** Aborting stops reading the body; the chunk is then discarded as incomplete. */
signal?: AbortSignal;
}): Promise<MediaUpload> {
const { services } = input;
const { repos, mediaRoot } = services.deps;
const row = await repos.getUploadRecord(input.spaceId, input.uploadId);
if (await expireIfIdle(services, row)) throw new UploadChunkError(409, "Upload expired");
if (row.status !== "open") throw new UploadChunkError(409, "Upload is not open");
const count = uploadChunkCount(row);
if (!Number.isInteger(input.index) || input.index < 0 || input.index >= count) {
throw new UploadChunkError(400, "Chunk index out of range");
}
const expected = expectedChunkBytes(row, input.index);
if (input.declaredLength !== null && input.declaredLength > expected) {
throw new UploadChunkError(413, "Chunk is too large");
}
if (!input.body) throw new UploadChunkError(400, "Chunk is empty");
const dir = await chunksDir(mediaRoot, row);
const finalPath = path.join(dir, `${input.index}.part`);
const tempPath = path.join(dir, `${input.index}.${randomUUID()}.tmp`);
const handle = await open(tempPath, "wx");
let written = 0;
let ok = false;
const reader = input.body.getReader();
const stop = () => void reader.cancel().catch(() => undefined);
input.signal?.addEventListener("abort", stop, { once: true });
try {
if (input.signal?.aborted) stop();
for (;;) {
const { done, value } = await reader.read();
if (done) break;
written += value.byteLength;
if (written > expected) {
void reader.cancel().catch(() => undefined);
throw new UploadChunkError(413, "Chunk is too large");
}
await handle.write(value);
}
if (written !== expected) throw new UploadChunkError(400, "Chunk is incomplete");
ok = true;
} finally {
input.signal?.removeEventListener("abort", stop);
reader.releaseLock();
await handle.close();
if (!ok) await rm(tempPath, { force: true });
}
await rename(tempPath, finalPath);
return repos.markChunkReceived(input.spaceId, input.uploadId, input.index);
}
const completing = new Set<string>();
/**
* Assemble a fully received upload, verify it, hand it to ingest, and close it.
* A checksum mismatch fails the upload so the client starts a fresh one.
*/
export async function completeMediaUpload(
services: MediaServices,
actor: MediaActor,
uploadId: string,
): Promise<MediaUpload> {
const { repos, mediaRoot } = services.deps;
const row = await repos.getUploadRecord(actor.spaceId, uploadId);
if (row.status === "complete") return repos.uploadStatus(actor.spaceId, uploadId);
if (await expireIfIdle(services, row)) throw new UploadChunkError(409, "Upload expired");
if (row.status !== "open") throw new MediaStateConflictError("Upload is not open");
const received = new Set(row.receivedChunks);
const count = uploadChunkCount(row);
for (let index = 0; index < count; index++) {
if (!received.has(index)) throw new MediaStateConflictError("Upload is missing chunks");
}
if (completing.has(row.id)) throw new MediaStateConflictError("Upload is already completing");
completing.add(row.id);
try {
const dir = await chunksDir(mediaRoot, row);
const relativeFile = `${uploadDir(row)}/file`;
const filePath = await mediaPath(mediaRoot, relativeFile);
const tempFile = await mediaPath(mediaRoot, `${uploadDir(row)}/file.${randomUUID()}.tmp`);
const hash = createHash("sha256");
let total = 0;
const out = await open(tempFile, "wx");
try {
for (let index = 0; index < count; index++) {
const chunkPath = path.join(dir, `${index}.part`);
const info = await stat(chunkPath).catch(() => null);
if (!info || info.size !== expectedChunkBytes(row, index)) {
throw new MediaStateConflictError("Upload is missing chunks");
}
for await (const buffer of createReadStream(chunkPath) as AsyncIterable<Buffer>) {
hash.update(buffer);
total += buffer.byteLength;
await out.write(buffer);
}
}
} catch (error) {
await out.close();
await rm(tempFile, { force: true });
throw error;
}
await out.close();
const sha256 = hash.digest("hex");
if (sha256 !== row.sha256 || BigInt(total) !== row.sizeBytes) {
await removeUploadFiles(mediaRoot, row);
await repos.failUpload(actor.spaceId, uploadId);
throw new UploadChunkError(400, "Upload does not match its checksum");
}
await rename(tempFile, filePath);
await rm(dir, { recursive: true, force: true });
await services.ingest.uploadCompleted(actor, {
assetId: row.assetId,
tempPath: filePath,
sha256,
fileName: row.fileName,
});
// Ingest normally moves the file into the asset folder and closes the
// upload itself; otherwise the assembled file stays as the original.
const after = await repos.getUploadRecord(actor.spaceId, uploadId);
if (after.status === "complete") {
await removeUploadFiles(mediaRoot, row);
return repos.uploadStatus(actor.spaceId, uploadId);
}
return repos.completeUpload(actor.spaceId, uploadId, { storagePath: relativeFile });
} finally {
completing.delete(row.id);
}
}

View File

@ -0,0 +1,516 @@
import { createHash } from "node:crypto";
import { mkdir, mkdtemp, readFile, rm, stat, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import { createRouterClient, ORPCError } from "@orpc/server";
import type { Actor } from "@rakazo/contracts";
import type { MediaRepos } from "@rakazo/db";
import { createMediaRepos } from "@rakazo/db";
import { Hono } from "hono";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import type { MediaTestDb, SeededMediaSpace } from "../../../packages/db/src/media-fixtures.js";
import {
openMediaTestDb,
seedMediaSpace,
sourceItem,
testBrandKit,
} from "../../../packages/db/src/media-fixtures.js";
import { testProfile } from "../../../packages/media/src/test-fixtures.js";
import { createMediaRouter } from "./media.js";
import { mountMediaHttpRoutes } from "./media-http.js";
import type { MediaServiceCall } from "./media-test-fakes.js";
import { fakeMediaServices, sampleMediaInputs } from "./media-test-fakes.js";
import { MEDIA_UPLOAD_IDLE_MS } from "./media-uploads.js";
const databaseUrl = process.env.DATABASE_URL;
const describePostgres =
process.env.VERIFY_DATABASE && databaseUrl ? describe.sequential : describe.skip;
type Space = { userId: string; spaceId: string };
function actorOf(space: Space, isDeploymentOwner = false): Actor {
return {
userId: space.userId,
spaceId: space.spaceId,
email: `${space.userId}@rakazo.test`,
isDeploymentOwner,
};
}
function procedure(client: unknown, rpcPath: string) {
let node = client;
for (const key of rpcPath.split("/")) node = (node as Record<string, unknown>)[key];
return node as (input: unknown) => Promise<unknown>;
}
async function rpcCode(promise: Promise<unknown>): Promise<string> {
try {
await promise;
} catch (error) {
if (error instanceof ORPCError) return error.code;
throw error;
}
return "OK";
}
function sha256(bytes: Uint8Array): string {
return createHash("sha256").update(bytes).digest("hex");
}
describePostgres("media API authorization and uploads (PostgreSQL)", () => {
let db: MediaTestDb;
let repos: MediaRepos;
let mediaRoot: string;
let a: Space;
let b: Space;
let seededA: SeededMediaSpace;
let seededB: SeededMediaSpace;
let calls: MediaServiceCall[];
let renderOutputs: Map<string, string>;
let rpc: (actor: Actor) => unknown;
let app: Hono;
let currentActor: Actor | null;
function setup(options: { now?: () => Date; chunkTimeoutMs?: number } = {}) {
const fake = fakeMediaServices({
repos,
mediaRoot,
prisma: db.prisma,
profiles: [testProfile()],
renderOutputs,
...(options.now ? { now: options.now } : {}),
});
calls = fake.calls;
const router = createMediaRouter({
prisma: db.prisma,
services: fake.services,
dataDir: path.join(mediaRoot, ".."),
});
rpc = (actor) => createRouterClient(router, { context: { actor } });
app = new Hono();
mountMediaHttpRoutes(
app,
{
services: fake.services,
...(options.chunkTimeoutMs ? { chunkTimeoutMs: options.chunkTimeoutMs } : {}),
},
async () => currentActor,
);
}
async function snapshot(spaceId: string) {
const p = db.prisma;
const where = { spaceId };
const orderBy = { id: "asc" as const };
return JSON.stringify(
[
await p.mediaSource.findMany({ where, orderBy }),
await p.mediaAsset.findMany({ where, orderBy }),
await p.mediaUpload.findMany({ where, orderBy }),
await p.mediaSegment.findMany({ where, orderBy }),
await p.mediaAnalysis.findMany({ where, orderBy }),
await p.mediaCollection.findMany({ where, orderBy }),
await p.mediaCollectionItem.findMany({
where: { collection: where },
orderBy: [{ collectionId: "asc" }, { assetId: "asc" }],
}),
await p.mediaPerson.findMany({ where, orderBy }),
await p.mediaFaceTrack.findMany({ where, orderBy }),
await p.mediaMark.findMany({ where, orderBy }),
await p.mediaBrandKit.findMany({ where, orderBy }),
await p.mediaProject.findMany({ where, orderBy }),
await p.mediaEditPlan.findMany({ where, orderBy }),
await p.mediaRender.findMany({ where, orderBy }),
await p.mediaPublication.findMany({ where, orderBy }),
await p.mediaMetric.findMany({ where, orderBy }),
await p.mediaMetricMapping.findMany({ where, orderBy }),
],
(_key, value) => (typeof value === "bigint" ? value.toString() : value),
);
}
async function artifactIn(space: Space): Promise<string> {
const artifact = await db.prisma.artifact.create({
data: {
spaceId: space.spaceId,
userId: space.userId,
name: "consent.pdf",
mimeType: "application/pdf",
size: 1,
hash: "0".repeat(64),
storageKey: `test/${space.spaceId}/consent.pdf`,
},
});
return artifact.id;
}
/** A device item in `space` that waits for its original. */
async function remoteAsset(space: Space, index: number): Promise<string> {
const source = await procedure(
rpc(actorOf(space)),
"sources/registerClient",
)({ kind: "ios_library", label: "Phone", deviceId: `device-${index}` });
const synced = await repos.upsertSourceItems(space.spaceId, (source as { id: string }).id, [
sourceItem(index),
]);
const assetId = synced.changedAssetIds[0] as string;
await repos.requestUpload(space.spaceId, [assetId]);
return assetId;
}
function putChunk(uploadId: string, index: number, body: BodyInit, actor: Actor | null) {
currentActor = actor;
return app.request(`/api/media/uploads/${uploadId}/chunks/${index}`, { method: "PUT", body });
}
beforeAll(async () => {
db = openMediaTestDb(databaseUrl!);
repos = createMediaRepos(db.prisma);
const root = await mkdtemp(path.join(tmpdir(), "media-api-pg-"));
mediaRoot = path.join(root, "media");
await mkdir(mediaRoot);
renderOutputs = new Map();
a = await db.newUser();
b = await db.newUser();
seededA = await seedMediaSpace(repos, a.spaceId, a.userId);
seededB = await seedMediaSpace(repos, b.spaceId, b.userId);
setup();
});
afterAll(async () => {
await db?.cleanup();
if (mediaRoot) await rm(path.dirname(mediaRoot), { recursive: true, force: true });
});
it("refuses another space's ids in every media procedure and leaves that space untouched", async () => {
const before = await snapshot(a.spaceId);
const foreignIds = Object.values(seededA);
const intruder = rpc(actorOf(b));
const inputs = sampleMediaInputs(seededA, testBrandKit);
for (const [rpcPath, sample] of Object.entries(inputs)) {
if (sample.foreign === "dedicated") continue;
const call = procedure(intruder, rpcPath)(sample.input);
if (sample.foreign === "not_found") {
expect(await rpcCode(call), rpcPath).toBe("NOT_FOUND");
continue;
}
const result = JSON.stringify(await call);
for (const id of foreignIds) expect(result, rpcPath).not.toContain(id);
}
expect(await snapshot(a.spaceId)).toBe(before);
// No service ever received the other space's ids.
const leaked = calls.filter((call) =>
foreignIds.some((id) => JSON.stringify(call.input ?? null).includes(id)),
);
expect(leaked).toEqual([]);
expect(calls.every((call) => call.actor.spaceId === b.spaceId)).toBe(true);
});
it("serves the same inputs inside the caller's own space", async () => {
// Positive control: NOT_FOUND above comes from isolation, not from bad samples.
const fabricated = new Set(["collections/merge", "people/merge"]);
for (const [rpcPath, sample] of Object.entries(sampleMediaInputs(seededA, testBrandKit))) {
if (sample.foreign !== "not_found" || fabricated.has(rpcPath)) continue;
const space = await db.newUser();
const seeded = await seedMediaSpace(repos, space.spaceId, space.userId);
const input = sampleMediaInputs(seeded, testBrandKit)[rpcPath]?.input;
const code = await rpcCode(procedure(rpc(actorOf(space)), rpcPath)(input));
expect(code, rpcPath).not.toBe("NOT_FOUND");
}
}, 120_000);
it("refuses another space's records referenced from the caller's own ones", async () => {
const own = rpc(actorOf(b));
const foreignArtifact = await artifactIn(a);
const attempts: Array<[string, unknown]> = [
["projects/update", { projectId: seededB.projectId, collectionIds: [seededA.collectionId] }],
["projects/update", { projectId: seededB.projectId, brandKitId: seededA.brandKitId }],
[
"projects/create",
{
title: "Mixed",
goal: "teaser",
assetIds: [seededA.assetId],
profileIds: ["example-short-vertical"],
},
],
["people/assignTrack", { trackId: seededB.trackId, personId: seededA.personId }],
[
"marks/create",
{
kind: "tattoo",
assetId: seededB.assetId,
personId: seededA.personId,
prompt: { tMs: 0 },
treatment: "blur",
},
],
["marks/update", { markId: seededB.markId, personId: seededA.personId }],
[
"assets/setPaidOnly",
{ segmentIds: [seededB.segmentId, seededA.segmentId], paidOnly: true },
],
["collections/merge", { collectionIds: [seededB.collectionId, seededA.collectionId] }],
["people/merge", { personIds: [seededB.personId, seededA.personId] }],
["brandKits/upsert", { kit: { ...testBrandKit, logoArtifactIds: [foreignArtifact] } }],
[
"people/recordConsent",
{
personId: seededB.personId,
consentArtifactId: foreignArtifact,
consentRecordedAt: "2026-09-01T10:00:00.000Z",
ageVerificationMethod: "document_on_file",
ageVerifiedAt: "2026-09-01T10:00:00.000Z",
},
],
];
const before = await snapshot(a.spaceId);
for (const [rpcPath, input] of attempts) {
expect(await rpcCode(procedure(own, rpcPath)(input)), rpcPath).toBe("NOT_FOUND");
}
expect(await snapshot(a.spaceId)).toBe(before);
const person = await db.prisma.mediaPerson.findUniqueOrThrow({
where: { id: seededB.personId },
});
expect(person.consentRecordedAt).toBeNull();
});
it("records consent only as the signed-in member and ignores it on updates", async () => {
const space = await db.newUser();
const seeded = await seedMediaSpace(repos, space.spaceId, space.userId);
const client = rpc(actorOf(space));
await procedure(
client,
"people/update",
)({
personId: seeded.personId,
displayName: "Person 1",
consentRecordedAt: "2026-09-01T10:00:00.000Z",
ageVerificationMethod: "platform_verified",
ageVerifiedAt: "2026-09-01T10:00:00.000Z",
});
let person = await db.prisma.mediaPerson.findUniqueOrThrow({ where: { id: seeded.personId } });
expect(person.consentRecordedAt).toBeNull();
expect(person.ageVerifiedAt).toBeNull();
const artifactId = await artifactIn(space);
await procedure(
client,
"people/recordConsent",
)({
personId: seeded.personId,
consentArtifactId: artifactId,
consentRecordedAt: "2026-09-01T10:00:00.000Z",
ageVerificationMethod: "platform_verified",
ageVerifiedAt: "2026-09-01T10:00:00.000Z",
});
person = await db.prisma.mediaPerson.findUniqueOrThrow({ where: { id: seeded.personId } });
expect(person.consentRecordedBy).toBe(space.userId);
expect(person.consentArtifactId).toBe(artifactId);
expect(person.ageVerificationMethod).toBe("platform_verified");
});
it("assembles a resumable chunked upload and hands it to ingest", async () => {
const assetId = await remoteAsset(b, 101);
const bytes = new TextEncoder().encode("0123456789");
const upload = await repos.beginUpload(b.spaceId, {
assetId,
userId: b.userId,
sizeBytes: bytes.length,
sha256: sha256(bytes),
fileName: "clip.mp4",
chunkBytes: 4,
});
const owner = actorOf(b);
expect((await putChunk(upload.id, 0, bytes.slice(0, 4), actorOf(a))).status).toBe(404);
expect((await putChunk(upload.id, 0, bytes.slice(0, 4), null)).status).toBe(401);
expect((await putChunk(upload.id, 3, bytes.slice(0, 2), owner)).status).toBe(400);
expect((await putChunk(upload.id, 0, bytes.slice(0, 5), owner)).status).toBe(413);
expect((await putChunk(upload.id, 0, bytes.slice(0, 3), owner)).status).toBe(400);
// Out of order and repeated chunks are fine; the client resumes from status.
expect((await putChunk(upload.id, 2, bytes.slice(8), owner)).status).toBe(200);
expect((await putChunk(upload.id, 0, bytes.slice(0, 4), owner)).status).toBe(200);
expect((await putChunk(upload.id, 0, bytes.slice(0, 4), owner)).status).toBe(200);
const client = rpc(owner);
const partial = (await procedure(client, "uploads/status")({ uploadId: upload.id })) as {
receivedChunks: number[];
};
expect([...partial.receivedChunks].sort()).toEqual([0, 2]);
expect(await rpcCode(procedure(client, "uploads/complete")({ uploadId: upload.id }))).toBe(
"CONFLICT",
);
expect((await putChunk(upload.id, 1, bytes.slice(4, 8), owner)).status).toBe(200);
calls.length = 0;
const done = (await procedure(client, "uploads/complete")({ uploadId: upload.id })) as {
status: string;
};
expect(done.status).toBe("complete");
const handed = calls.filter((call) => call.method === "uploadCompleted");
expect(handed).toHaveLength(1);
const input = handed[0]?.input as { tempPath: string; sha256: string; assetId: string };
expect(input.assetId).toBe(assetId);
expect(input.sha256).toBe(sha256(bytes));
expect(input.tempPath.startsWith(path.join(mediaRoot, b.spaceId, "uploads"))).toBe(true);
expect(await readFile(input.tempPath, "utf8")).toBe("0123456789");
const asset = await repos.getAssetRecord(b.spaceId, assetId);
expect(asset.storage).toBe("uploaded");
expect(asset.storagePath).toBe(`${b.spaceId}/uploads/${upload.id}/file`);
// Completing again is idempotent and does not hand the file over twice.
await procedure(client, "uploads/complete")({ uploadId: upload.id });
expect(calls.filter((call) => call.method === "uploadCompleted")).toHaveLength(1);
expect((await putChunk(upload.id, 0, bytes.slice(0, 4), owner)).status).toBe(409);
});
it("opens uploads through the RPC only for items that wait for their original", async () => {
const client = rpc(actorOf(b));
const assetId = await remoteAsset(b, 102);
const input = { assetId, sizeBytes: 10, sha256: "a".repeat(64), fileName: "clip.mp4" };
const first = (await procedure(client, "uploads/begin")(input)) as { id: string };
const resumed = (await procedure(client, "uploads/begin")(input)) as { id: string };
expect(resumed.id).toBe(first.id);
const referenced = { ...input, assetId: seededB.assetId };
expect(await rpcCode(procedure(client, "uploads/begin")(referenced))).toBe("CONFLICT");
const tooLarge = { ...input, sizeBytes: 21 * 1024 * 1024 * 1024 };
expect(await rpcCode(procedure(client, "uploads/begin")(tooLarge))).toBe("BAD_REQUEST");
});
it("fails an upload whose bytes do not match the announced checksum", async () => {
const assetId = await remoteAsset(b, 103);
const bytes = new TextEncoder().encode("abcdef");
const upload = await repos.beginUpload(b.spaceId, {
assetId,
userId: b.userId,
sizeBytes: bytes.length,
sha256: "f".repeat(64),
fileName: "clip.mp4",
chunkBytes: 4,
});
const owner = actorOf(b);
await putChunk(upload.id, 0, bytes.slice(0, 4), owner);
await putChunk(upload.id, 1, bytes.slice(4), owner);
const client = rpc(owner);
expect(await rpcCode(procedure(client, "uploads/complete")({ uploadId: upload.id }))).toBe(
"BAD_REQUEST",
);
const status = (await procedure(client, "uploads/status")({ uploadId: upload.id })) as {
status: string;
};
expect(status.status).toBe("failed");
const dir = path.join(mediaRoot, b.spaceId, "uploads", upload.id);
await expect(stat(dir)).rejects.toMatchObject({ code: "ENOENT" });
});
it("expires an idle upload and starts a fresh one", async () => {
const assetId = await remoteAsset(b, 104);
const input = { assetId, sizeBytes: 10, sha256: "b".repeat(64), fileName: "clip.mp4" };
const first = (await procedure(rpc(actorOf(b)), "uploads/begin")(input)) as { id: string };
setup({ now: () => new Date(Date.now() + MEDIA_UPLOAD_IDLE_MS + 60_000) });
try {
const response = await putChunk(first.id, 0, new Uint8Array(10), actorOf(b));
expect(response.status).toBe(409);
const status = (await procedure(
rpc(actorOf(b)),
"uploads/status",
)({
uploadId: first.id,
})) as { status: string };
expect(status.status).toBe("failed");
const fresh = (await procedure(rpc(actorOf(b)), "uploads/begin")(input)) as { id: string };
expect(fresh.id).not.toBe(first.id);
} finally {
setup();
}
});
it("drops a chunk that does not arrive in time", async () => {
const assetId = await remoteAsset(b, 105);
const upload = await repos.beginUpload(b.spaceId, {
assetId,
userId: b.userId,
sizeBytes: 10,
sha256: "c".repeat(64),
fileName: "clip.mp4",
});
setup({ chunkTimeoutMs: 50 });
try {
const stalled = new ReadableStream<Uint8Array>({
start(controller) {
controller.enqueue(new Uint8Array(2));
},
});
currentActor = actorOf(b);
const response = await app.request(`/api/media/uploads/${upload.id}/chunks/0`, {
method: "PUT",
body: stalled,
duplex: "half",
} as RequestInit);
expect(response.status).toBe(408);
const status = await repos.uploadStatus(b.spaceId, upload.id);
expect(status.receivedChunks).toEqual([]);
} finally {
setup();
}
});
it("streams render files only inside the caller's space and render folder", async () => {
const renderDir = path.join(mediaRoot, a.spaceId, "renders", seededA.renderId);
await mkdir(renderDir, { recursive: true });
const video = path.join(renderDir, "video.mp4");
await writeFile(video, "0123456789");
renderOutputs.set(seededA.renderId, video);
const url = `/api/media/renders/${seededA.renderId}/files/video.mp4`;
currentActor = actorOf(a);
const full = await app.request(url);
expect(full.status).toBe(200);
expect(await full.text()).toBe("0123456789");
const ranged = await app.request(url, { headers: { range: "bytes=2-4" } });
expect(ranged.status).toBe(206);
expect(ranged.headers.get("content-range")).toBe("bytes 2-4/10");
expect(await ranged.text()).toBe("234");
const download = await app.request(`${url}?download=1`);
expect(download.headers.get("content-disposition")).toContain("attachment");
// A render that has not passed its gate never leaves as a download.
const queuedVideo = path.join(mediaRoot, a.spaceId, "renders", seededA.queuedRenderId, "v.mp4");
await mkdir(path.dirname(queuedVideo), { recursive: true });
await writeFile(queuedVideo, "x");
renderOutputs.set(seededA.queuedRenderId, queuedVideo);
const queuedUrl = `/api/media/renders/${seededA.queuedRenderId}/files/v.mp4`;
expect((await app.request(queuedUrl)).status).toBe(200);
expect((await app.request(`${queuedUrl}?download=1`)).status).toBe(404);
// A path the service reports outside the render folder is refused.
const outside = path.join(mediaRoot, a.spaceId, "video.mp4");
await writeFile(outside, "secret");
renderOutputs.set(seededA.renderId, outside);
expect((await app.request(url)).status).toBe(404);
renderOutputs.set(seededA.renderId, video);
currentActor = actorOf(b);
expect((await app.request(url)).status).toBe(404);
expect((await app.request(`/api/media/assets/${seededA.assetId}/thumb`)).status).toBe(404);
expect((await app.request(`/api/media/bundles/${seededA.publicationId}`)).status).toBe(404);
});
it("refuses a folder source to members who do not own the deployment", async () => {
const folder = await mkdtemp(path.join(tmpdir(), "media-footage-"));
try {
const input = { label: "Footage", path: folder };
const member = procedure(rpc(actorOf(b)), "sources/createFolder")(input);
expect(await rpcCode(member)).toBe("FORBIDDEN");
const created = (await procedure(rpc(actorOf(b, true)), "sources/createFolder")(input)) as {
id: string;
kind: string;
};
expect(created.kind).toBe("folder");
const row = await db.prisma.mediaSource.findUniqueOrThrow({ where: { id: created.id } });
expect(row.ownerUserId).toBe(b.userId);
} finally {
await rm(folder, { recursive: true, force: true });
}
});
});

333
apps/api/src/media.test.ts Normal file
View File

@ -0,0 +1,333 @@
import { mkdir, mkdtemp, rm, symlink } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import { createRouterClient, ORPCError } from "@orpc/server";
import type { Actor } from "@rakazo/contracts";
import { MediaToolInputSchemas, mediaRpcShapes } from "@rakazo/contracts";
import type { MediaRepos, PrismaClient } from "@rakazo/db";
import { InvalidMediaInputError, IsolationError, MediaStateConflictError } from "@rakazo/db";
import { PathOutsideRootError } from "@rakazo/media/node";
import { Hono } from "hono";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import {
createMediaRouter,
MEDIA_NOT_CONFIGURED,
mediaRpcError,
unconfiguredCapabilities,
validateMediaFolder,
} from "./media.js";
import { mountMediaHttpRoutes, parseByteRange } from "./media-http.js";
import type { MediaSampleIds } from "./media-test-fakes.js";
import { fakeMediaServices, mediaProcedurePaths, sampleMediaInputs } from "./media-test-fakes.js";
import { expectedChunkBytes, uploadChunkCount } from "./media-uploads.js";
const member: Actor = {
userId: "user-member",
spaceId: "space-a",
email: "member@rakazo.test",
isDeploymentOwner: false,
};
const owner: Actor = { ...member, userId: "user-owner", isDeploymentOwner: true };
const ids = Object.fromEntries(
[
"sourceId",
"assetId",
"secondAssetId",
"segmentId",
"trackId",
"personId",
"markId",
"collectionId",
"brandKitId",
"projectId",
"planId",
"renderId",
"publicationId",
"mappingId",
"uploadId",
].map((key) => [key, `id-${key.toLowerCase()}`]),
) as unknown as MediaSampleIds;
const kit = {
name: "Kit",
colors: {
primary: "#101010",
secondary: "#202020",
accent: "#303030",
text: "#f0f0f0",
background: "#000000",
},
};
function client(router: ReturnType<typeof createMediaRouter>, actor: Actor | null) {
return createRouterClient(router, { context: { actor } });
}
function procedure(target: unknown, rpcPath: string) {
let node: unknown = target;
for (const key of rpcPath.split("/")) node = (node as Record<string, unknown>)[key];
return node as (input: unknown) => Promise<unknown>;
}
async function rpcCode(promise: Promise<unknown>): Promise<string> {
try {
await promise;
} catch (error) {
if (error instanceof ORPCError) return error.code;
throw error;
}
return "OK";
}
/** Repositories that fail the test when reached. */
function untouchedRepos(): MediaRepos {
return new Proxy({} as MediaRepos, {
get: (_target, key) => () => {
throw new Error(`repository ${String(key)} must not be called`);
},
});
}
const prismaStub = {} as PrismaClient;
describe("media router without a configured sidecar", () => {
const router = createMediaRouter({ prisma: prismaStub, services: null, dataDir: "/data" });
const inputs = sampleMediaInputs(ids, kit);
it("has a sample input for every media procedure", () => {
expect(Object.keys(inputs).sort()).toEqual(mediaProcedurePaths().sort());
});
it("reports media as not configured so every surface keeps it hidden", async () => {
const capabilities = await client(router, member).capabilities({});
expect(capabilities).toEqual(unconfiguredCapabilities());
expect(capabilities.configured).toBe(false);
expect(
Object.values(capabilities.operations).every(
(op) => !(op as { available: boolean }).available,
),
).toBe(true);
});
it("refuses every other procedure", async () => {
for (const [rpcPath, sample] of Object.entries(inputs)) {
if (rpcPath === "capabilities") continue;
const promise = procedure(client(router, member), rpcPath)(sample.input);
await expect(promise, rpcPath).rejects.toMatchObject({
code: "PRECONDITION_FAILED",
message: MEDIA_NOT_CONFIGURED,
});
}
});
it("requires a signed-in member for every procedure", async () => {
for (const [rpcPath, sample] of Object.entries(inputs)) {
const code = await rpcCode(procedure(client(router, null), rpcPath)(sample.input));
expect(code, rpcPath).toBe("UNAUTHORIZED");
}
});
it("answers the byte routes with 404 and anonymous callers with 401", async () => {
const app = new Hono();
let actor: Actor | null = member;
mountMediaHttpRoutes(app, { services: null }, async () => actor);
const requests = [
new Request("http://api/api/media/uploads/u1/chunks/0", { method: "PUT", body: "abcd" }),
new Request("http://api/api/media/renders/r1/files/video.mp4"),
new Request("http://api/api/media/assets/a1/thumb"),
new Request("http://api/api/media/assets/a1/proxy"),
new Request("http://api/api/media/bundles/p1"),
];
for (const request of requests) {
expect((await app.request(request.clone())).status, request.url).toBe(404);
}
actor = null;
for (const request of requests) {
expect((await app.request(request.clone())).status, request.url).toBe(401);
}
});
});
describe("media router permissions", () => {
let root: string;
beforeAll(async () => {
root = await mkdtemp(path.join(tmpdir(), "media-api-"));
});
afterAll(async () => {
await rm(root, { recursive: true, force: true });
});
it("lets only the deployment owner add a folder source", async () => {
const created: unknown[] = [];
const repos = new Proxy({} as MediaRepos, {
get: (_target, key) => {
if (key === "createSource") {
return async (_spaceId: string, input: unknown) => {
created.push(input);
return {
id: "src-1",
kind: "folder",
label: "Footage",
status: "idle",
assetCount: 0,
lastSyncedAt: null,
error: null,
createdAt: "2026-10-01T00:00:00.000Z",
};
};
}
return () => {
throw new Error(`repository ${String(key)} must not be called`);
};
},
});
const { services, calls } = fakeMediaServices({ repos, mediaRoot: path.join(root, "media") });
const footage = path.join(root, "footage");
await mkdir(footage);
const router = createMediaRouter({
prisma: prismaStub,
services,
dataDir: path.join(root, "data"),
appRoot: path.join(root, "app"),
});
const denied = client(router, member).sources.createFolder({ label: "Footage", path: footage });
await expect(denied).rejects.toMatchObject({ code: "FORBIDDEN" });
expect(created).toEqual([]);
expect(calls).toEqual([]);
await client(router, owner).sources.createFolder({ label: "Footage", path: footage });
expect(created).toEqual([
{ kind: "folder", label: "Footage", ownerUserId: owner.userId, config: { path: footage } },
]);
expect(calls.map((call) => `${call.service}.${call.method}`)).toEqual(["ingest.requestSync"]);
});
it("accepts only a real, specific folder apart from evOo's own directories", async () => {
const dataDir = path.join(root, "data");
const appRoot = path.join(root, "app");
const folder = path.join(root, "clips");
await mkdir(path.join(dataDir, "media"), { recursive: true });
await mkdir(path.join(appRoot, "packages"), { recursive: true });
await mkdir(folder, { recursive: true });
await symlink(folder, path.join(root, "clips-link"));
const forbidden = { dataDir, appRoot };
await expect(validateMediaFolder(folder, forbidden)).resolves.toBe(folder);
const rejected = [
"clips",
path.join(root, "missing"),
path.join(root, "clips-link"),
"/",
dataDir,
path.join(dataDir, "media"),
root,
path.join(appRoot, "packages"),
];
for (const candidate of rejected) {
await expect(validateMediaFolder(candidate, forbidden), candidate).rejects.toBeInstanceOf(
InvalidMediaInputError,
);
}
});
});
describe("consent stays with the signed-in user", () => {
it("never accepts consent or age fields from the agent's people tool", () => {
const schema = MediaToolInputSchemas.media_people;
expect(schema.shape.action.options).toEqual(["list", "update", "merge"]);
const parsed = schema.parse({
action: "update",
personId: "person-1",
consentArtifactId: "artifact-1",
consentRecordedAt: "2026-09-01T00:00:00.000Z",
ageVerificationMethod: "platform_verified",
ageVerifiedAt: "2026-09-01T00:00:00.000Z",
});
expect(Object.keys(parsed).sort()).toEqual(["action", "personId"]);
});
it("drops consent fields sent to people.update", async () => {
const patches: unknown[] = [];
const repos = new Proxy({} as MediaRepos, {
get: (_target, key) => {
if (key === "updatePerson") {
return async (_spaceId: string, _personId: string, patch: unknown) => {
patches.push(patch);
return {};
};
}
return () => {
throw new Error(`repository ${String(key)} must not be called`);
};
},
});
const { services } = fakeMediaServices({ repos, mediaRoot: "/unused" });
const router = createMediaRouter({ prisma: prismaStub, services, dataDir: "/data" });
await procedure(
client(router, member),
"people/update",
)({
personId: "person-1",
displayName: "Person 1",
consentRecordedAt: "2026-09-01T00:00:00.000Z",
ageVerificationMethod: "platform_verified",
ageVerifiedAt: "2026-09-01T00:00:00.000Z",
}).catch(() => undefined);
expect(patches).toHaveLength(1);
expect(Object.entries(patches[0] as object).filter(([, value]) => value !== undefined)).toEqual(
[["displayName", "Person 1"]],
);
});
it("marks recordConsent as the only user-only write and keeps it out of the services", () => {
const people = mediaRpcShapes.people as Record<string, unknown>;
expect(Object.keys(people)).toContain("recordConsent");
const { services } = fakeMediaServices({ repos: untouchedRepos(), mediaRoot: "/unused" });
const serviceMethods = (["ingest", "privacy", "render", "marketing"] as const).flatMap((name) =>
Object.keys(services[name] as unknown as Record<string, unknown>),
);
expect(serviceMethods.filter((method) => /consent|age/i.test(method))).toEqual([]);
});
});
describe("media error mapping", () => {
it("never reveals whether a foreign id exists", () => {
expect((mediaRpcError(new IsolationError()) as ORPCError<string, unknown>).code).toBe(
"NOT_FOUND",
);
expect(
(mediaRpcError(new PathOutsideRootError("../x")) as ORPCError<string, unknown>).code,
).toBe("BAD_REQUEST");
expect(
(mediaRpcError(new MediaStateConflictError("busy")) as ORPCError<string, unknown>).code,
).toBe("CONFLICT");
const other = new Error("boom");
expect(mediaRpcError(other)).toBe(other);
});
});
describe("upload and streaming helpers", () => {
it("splits uploads into full chunks plus a shorter last one", () => {
const row = { sizeBytes: 10n, chunkBytes: 4 };
expect(uploadChunkCount(row)).toBe(3);
expect([0, 1, 2].map((index) => expectedChunkBytes(row, index))).toEqual([4, 4, 2]);
expect(uploadChunkCount({ sizeBytes: 8n, chunkBytes: 4 })).toBe(2);
expect(expectedChunkBytes({ sizeBytes: 8n, chunkBytes: 4 }, 1)).toBe(4);
});
it("parses single byte ranges and refuses unsatisfiable ones", () => {
expect(parseByteRange(undefined, 100)).toBeNull();
expect(parseByteRange("bytes=0-9", 100)).toEqual({ start: 0, end: 9 });
expect(parseByteRange("bytes=90-", 100)).toEqual({ start: 90, end: 99 });
expect(parseByteRange("bytes=-10", 100)).toEqual({ start: 90, end: 99 });
expect(parseByteRange("bytes=50-500", 100)).toEqual({ start: 50, end: 99 });
expect(parseByteRange("bytes=0-1,5-6", 100)).toBeNull();
expect(parseByteRange("bytes=100-", 100)).toBe("unsatisfiable");
expect(parseByteRange("bytes=9-3", 100)).toBe("unsatisfiable");
expect(parseByteRange("items=0-1", 100)).toBe("unsatisfiable");
expect(parseByteRange("bytes=-0", 100)).toBe("unsatisfiable");
});
});

552
apps/api/src/media.ts Normal file
View File

@ -0,0 +1,552 @@
import { lstat, realpath, rm } from "node:fs/promises";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { implement, ORPCError } from "@orpc/server";
import { UnsupportedMediaOperationError } from "@rakazo/adapter-kit";
import type { MediaServices } from "@rakazo/adapters";
import { recordMetric, resetTeaserModel, saveBrandKit, segmentFunnel } from "@rakazo/adapters";
import type { Actor, BrandKit, MediaCapabilities, MediaSourceKind } from "@rakazo/contracts";
import { appContract, MediaInferenceOperationSchema } from "@rakazo/contracts";
import type { PrismaClient } from "@rakazo/db";
import {
InvalidMediaInputError,
InvalidProjectWorkspacePathError,
IsolationError,
lockMediaKey,
MediaStateConflictError,
mapMediaSource,
resolveProjectWorkspaceDirectory,
} from "@rakazo/db";
import { summarizeProfile } from "@rakazo/media";
import { PathOutsideRootError, resolveInside } from "@rakazo/media/node";
import { beginMediaUpload, completeMediaUpload, UploadChunkError } from "./media-uploads.js";
export interface MediaRouterDeps {
prisma: PrismaClient;
/** Null when Media Studio is not configured: everything but capabilities refuses. */
services?: MediaServices | null;
/** DATA_DIR; a folder source may neither sit inside it nor contain it. */
dataDir: string;
/** Application install directory a folder source may not point into. */
appRoot?: string;
}
export const MEDIA_NOT_CONFIGURED = "Media Studio is not configured";
const DEFAULT_APP_ROOT = fileURLToPath(new URL("../../../", import.meta.url));
/** Translate domain errors into RPC errors; everything else passes through. */
export function mediaRpcError(error: unknown): unknown {
if (error instanceof ORPCError) return error;
if (error instanceof IsolationError) return new ORPCError("NOT_FOUND");
if (
error instanceof InvalidMediaInputError ||
error instanceof InvalidProjectWorkspacePathError
) {
return new ORPCError("BAD_REQUEST", { message: error.message });
}
if (error instanceof PathOutsideRootError) {
return new ORPCError("BAD_REQUEST", { message: "That path is not allowed." });
}
if (error instanceof MediaStateConflictError) {
return new ORPCError("CONFLICT", { message: error.message });
}
if (error instanceof UploadChunkError) {
return new ORPCError(error.status === 409 ? "CONFLICT" : "BAD_REQUEST", {
message: error.message,
});
}
if (error instanceof UnsupportedMediaOperationError) {
return new ORPCError("PRECONDITION_FAILED", { message: error.message });
}
return error;
}
export function unconfiguredCapabilities(): MediaCapabilities {
const unavailable = {
available: false,
experimental: false,
model: null,
license: null,
commercialUse: null,
};
return {
configured: false,
operations: Object.fromEntries(
MediaInferenceOperationSchema.options.map((operation) => [operation, unavailable]),
) as MediaCapabilities["operations"],
accelerator: "none",
hardwareEncoders: [],
};
}
function overlaps(a: string, b: string): boolean {
const inside = (root: string, candidate: string) => {
const relative = path.relative(root, candidate);
return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative));
};
return inside(a, b) || inside(b, a);
}
async function realOrResolved(target: string): Promise<string> {
return realpath(target).catch(() => path.resolve(target));
}
/**
* A host folder for a folder source: absolute, an existing directory, not a
* symlink, and apart from DATA_DIR and the application itself. Returns the real path.
*/
export async function validateMediaFolder(
input: string,
forbidden: { dataDir: string; appRoot: string },
): Promise<string> {
const trimmed = input.trim();
if (!path.isAbsolute(trimmed)) {
throw new InvalidMediaInputError("Use an absolute folder path.");
}
const info = await lstat(trimmed).catch(() => null);
if (!info) throw new InvalidMediaInputError("That folder does not exist or is not readable.");
if (info.isSymbolicLink())
throw new InvalidMediaInputError("Choose the folder, not a link to it.");
const resolved = await resolveProjectWorkspaceDirectory(trimmed);
if (path.parse(resolved).root === resolved) {
throw new InvalidMediaInputError("Choose a specific folder.");
}
const dataDir = await realOrResolved(forbidden.dataDir);
const appRoot = await realOrResolved(forbidden.appRoot);
const relativeToApp = path.relative(appRoot, resolved);
const insideApp =
relativeToApp === "" || (!relativeToApp.startsWith("..") && !path.isAbsolute(relativeToApp));
if (overlaps(dataDir, resolved) || insideApp) {
throw new InvalidMediaInputError("That folder belongs to evOo itself.");
}
return resolved;
}
/** Artifacts referenced from media records must belong to the caller's space. */
export async function assertSpaceArtifacts(
prisma: PrismaClient,
spaceId: string,
artifactIds: string[],
): Promise<void> {
const ids = [...new Set(artifactIds)];
if (ids.length === 0) return;
const owned = await prisma.artifact.count({ where: { spaceId, id: { in: ids } } });
if (owned !== ids.length) throw new IsolationError();
}
function brandKitArtifactIds(kit: BrandKit): string[] {
return [
...kit.fonts.map((font) => font.artifactId),
...kit.logoArtifactIds,
...(kit.lutArtifactId ? [kit.lutArtifactId] : []),
...kit.musicBeds.map((bed) => bed.artifactId),
];
}
async function removeMediaPath(mediaRoot: string, relative: string | null): Promise<void> {
if (!relative) return;
await rm(await resolveInside(mediaRoot, relative), { recursive: true, force: true });
}
export function createMediaRouter(deps: MediaRouterDeps) {
const os = implement(appContract.media).$context<{ actor: Actor | null; signal?: AbortSignal }>();
const authed = os.use(async ({ context, next }) => {
if (!context.actor) throw new ORPCError("UNAUTHORIZED");
try {
return await next({ context: { ...context, actor: context.actor } });
} catch (error) {
throw mediaRpcError(error);
}
});
const media = authed.use(async ({ next }) => {
if (!deps.services) {
throw new ORPCError("PRECONDITION_FAILED", { message: MEDIA_NOT_CONFIGURED });
}
return next({ context: { services: deps.services } });
});
const forbiddenRoots = { dataDir: deps.dataDir, appRoot: deps.appRoot ?? DEFAULT_APP_ROOT };
async function knownProfiles(services: MediaServices, profileIds: string[] | undefined) {
if (!profileIds) return;
const { profiles } = await services.deps.profiles();
const unknown = profileIds.filter((id) => !profiles.has(id));
if (unknown.length > 0) {
throw new InvalidMediaInputError(`Unknown profile: ${unknown.join(", ")}`);
}
}
return os.router({
capabilities: authed.capabilities.handler(async ({ context }) => {
const services = deps.services;
if (!services) return unconfiguredCapabilities();
const hardwareEncoders = await services.deps.renderer.hardwareEncoders().catch(() => []);
const inference = services.deps.inference;
if (!inference) return { ...unconfiguredCapabilities(), hardwareEncoders };
const capabilities = await inference
.capabilities(context.signal)
.catch(() => ({ ...unconfiguredCapabilities(), configured: true }));
return { ...capabilities, configured: true, hardwareEncoders };
}),
sources: {
list: media.sources.list.handler(({ context }) =>
context.services.deps.repos.listSources(context.actor.spaceId),
),
createFolder: media.sources.createFolder.handler(async ({ context, input }) => {
// A folder lives on the host, and the host is the deployment owner's machine.
if (!context.actor.isDeploymentOwner) throw new ORPCError("FORBIDDEN");
const folder = await validateMediaFolder(input.path, forbiddenRoots);
const { repos } = context.services.deps;
const source = await repos.createSource(context.actor.spaceId, {
kind: "folder",
label: input.label,
ownerUserId: context.actor.userId,
config: { path: folder },
});
await context.services.ingest.requestSync(context.actor, source.id);
return source;
}),
registerClient: media.sources.registerClient.handler(async ({ context, input }) => {
const { spaceId, userId } = context.actor;
const kind: MediaSourceKind = input.kind;
// One source per device: re-registering returns the existing one.
return deps.prisma.$transaction(async (tx) => {
await lockMediaKey(tx, `source:${spaceId}:${kind}:${input.deviceId}`);
const existing = await tx.mediaSource.findFirst({
where: { spaceId, kind, config: { path: ["deviceId"], equals: input.deviceId } },
orderBy: [{ createdAt: "asc" }, { id: "asc" }],
include: { _count: { select: { assets: true } } },
});
if (existing) return mapMediaSource(existing, existing._count.assets);
const created = await tx.mediaSource.create({
data: {
spaceId,
kind,
label: input.label,
ownerUserId: userId,
config: { deviceId: input.deviceId },
},
});
return mapMediaSource(created, 0);
});
}),
pushItems: media.sources.pushItems.handler(async ({ context, input }) => {
await context.services.deps.repos.getSource(context.actor.spaceId, input.sourceId);
return context.services.ingest.pushItems(context.actor, input);
}),
sync: media.sources.sync.handler(async ({ context, input }) => {
await context.services.deps.repos.getSource(context.actor.spaceId, input.sourceId);
await context.services.ingest.requestSync(context.actor, input.sourceId);
return { ok: true as const };
}),
remove: media.sources.remove.handler(async ({ context, input }) => {
await context.services.deps.repos.getSource(context.actor.spaceId, input.sourceId);
await context.services.ingest.removeSource(context.actor, input.sourceId);
return { ok: true as const };
}),
},
assets: {
search: media.assets.search.handler(({ context, input }) =>
context.services.deps.repos.searchAssets(context.actor.spaceId, input),
),
get: media.assets.get.handler(({ context, input }) =>
context.services.deps.repos.getAsset(context.actor.spaceId, input.assetId),
),
setPaidOnly: media.assets.setPaidOnly.handler(async ({ context, input }) => {
await context.services.deps.repos.setSegmentsPaidOnly(
context.actor.spaceId,
input.segmentIds,
input.paidOnly,
);
return { ok: true as const };
}),
requestUpload: media.assets.requestUpload.handler(async ({ context, input }) => ({
awaiting: await context.services.deps.repos.requestUpload(
context.actor.spaceId,
input.assetIds,
),
})),
},
uploads: {
begin: media.uploads.begin.handler(({ context, input }) =>
beginMediaUpload(context.services, context.actor, input),
),
status: media.uploads.status.handler(({ context, input }) =>
context.services.deps.repos.uploadStatus(context.actor.spaceId, input.uploadId),
),
complete: media.uploads.complete.handler(({ context, input }) =>
completeMediaUpload(context.services, context.actor, input.uploadId),
),
},
collections: {
list: media.collections.list.handler(({ context, input }) =>
context.services.deps.repos.listCollections(context.actor.spaceId, input),
),
get: media.collections.get.handler(({ context, input }) =>
context.services.deps.repos.getCollection(context.actor.spaceId, input.collectionId),
),
rename: media.collections.rename.handler(({ context, input }) =>
context.services.deps.repos.renameCollection(
context.actor.spaceId,
input.collectionId,
input.title,
),
),
merge: media.collections.merge.handler(({ context, input }) =>
context.services.deps.repos.mergeCollections(context.actor.spaceId, input.collectionIds),
),
split: media.collections.split.handler(({ context, input }) =>
context.services.deps.repos.splitCollection(
context.actor.spaceId,
input.collectionId,
input.atAssetId,
),
),
createManual: media.collections.createManual.handler(({ context, input }) =>
context.services.deps.repos.createManualCollection(
context.actor.spaceId,
input.title,
input.assetIds,
),
),
recluster: media.collections.recluster.handler(async ({ context }) => {
await context.services.ingest.recluster(context.actor);
return { ok: true as const };
}),
},
people: {
list: media.people.list.handler(({ context }) =>
context.services.deps.repos.listPeople(context.actor.spaceId),
),
update: media.people.update.handler(({ context, input }) =>
context.services.deps.repos.updatePerson(context.actor.spaceId, input.personId, {
displayName: input.displayName,
isSelf: input.isSelf,
confirmed: input.confirmed,
anonymizeByDefault: input.anonymizeByDefault,
}),
),
merge: media.people.merge.handler(({ context, input }) =>
context.services.deps.repos.mergePeople(context.actor.spaceId, input.personIds),
),
assignTrack: media.people.assignTrack.handler(({ context, input }) =>
context.services.deps.repos.assignTrack(
context.actor.spaceId,
input.trackId,
input.personId,
),
),
// The only consent write path: the signed-in member is the recorder.
recordConsent: media.people.recordConsent.handler(async ({ context, input }) => {
await assertSpaceArtifacts(
deps.prisma,
context.actor.spaceId,
input.consentArtifactId ? [input.consentArtifactId] : [],
);
return context.services.deps.repos.recordConsent(
{ spaceId: context.actor.spaceId, recordedByUserId: context.actor.userId },
input,
);
}),
remove: media.people.remove.handler(async ({ context, input }) => {
await context.services.deps.repos.getPerson(context.actor.spaceId, input.personId);
await context.services.privacy.removePerson(context.actor, input.personId);
return { ok: true as const };
}),
},
marks: {
list: media.marks.list.handler(({ context, input }) =>
context.services.deps.repos.listMarks(context.actor.spaceId, {
...(input.personId ? { personId: input.personId } : {}),
...(input.assetId ? { assetId: input.assetId } : {}),
}),
),
create: media.marks.create.handler(async ({ context, input }) => {
const { repos } = context.services.deps;
await repos.getAssetRecord(context.actor.spaceId, input.assetId);
if (input.personId) await repos.getPerson(context.actor.spaceId, input.personId);
return context.services.privacy.createMark(context.actor, input);
}),
update: media.marks.update.handler(async ({ context, input }) => {
const { repos } = context.services.deps;
await repos.getMark(context.actor.spaceId, input.markId);
if (input.personId) await repos.getPerson(context.actor.spaceId, input.personId);
return context.services.privacy.updateMark(context.actor, input);
}),
remove: media.marks.remove.handler(async ({ context, input }) => {
const { repos, mediaRoot } = context.services.deps;
const { maskDir } = await repos.removeMark(context.actor.spaceId, input.markId);
await removeMediaPath(mediaRoot, maskDir);
return { ok: true as const };
}),
},
brandKits: {
list: media.brandKits.list.handler(({ context }) =>
context.services.deps.repos.listBrandKits(context.actor.spaceId),
),
upsert: media.brandKits.upsert.handler(async ({ context, input }) => {
await assertSpaceArtifacts(
deps.prisma,
context.actor.spaceId,
brandKitArtifactIds(input.kit),
);
return saveBrandKit(context.services.deps, context.actor, input);
}),
remove: media.brandKits.remove.handler(async ({ context, input }) => {
await context.services.deps.repos.removeBrandKit(context.actor.spaceId, input.id);
return { ok: true as const };
}),
templates: media.brandKits.templates.handler(({ context }) =>
context.services.deps.motion.templates(),
),
introVariants: media.brandKits.introVariants.handler(async ({ context, input }) => {
await context.services.deps.repos.getBrandKit(context.actor.spaceId, input.id);
return context.services.marketing.introVariants(context.actor, {
brandKitId: input.id,
count: input.count,
});
}),
},
profiles: {
list: media.profiles.list.handler(async ({ context }) => {
const { profiles } = await context.services.deps.profiles();
const now = context.services.deps.now();
return [...profiles.values()].map(({ profile, origin }) =>
summarizeProfile(profile, origin, now),
);
}),
get: media.profiles.get.handler(async ({ context, input }) => {
const { profiles } = await context.services.deps.profiles();
const entry = profiles.get(input.profileId);
if (!entry) throw new ORPCError("NOT_FOUND");
return entry.profile;
}),
},
projects: {
list: media.projects.list.handler(({ context, input }) =>
context.services.deps.repos.listProjects(context.actor.spaceId, input),
),
get: media.projects.get.handler(({ context, input }) =>
context.services.deps.repos.getProject(context.actor.spaceId, input.projectId),
),
create: media.projects.create.handler(async ({ context, input }) => {
await knownProfiles(context.services, input.profileIds);
return context.services.deps.repos.createProject(
context.actor.spaceId,
context.actor.userId,
input,
);
}),
update: media.projects.update.handler(async ({ context, input }) => {
await knownProfiles(context.services, input.profileIds);
const { projectId, ...patch } = input;
return context.services.deps.repos.updateProject(context.actor.spaceId, projectId, patch);
}),
remove: media.projects.remove.handler(async ({ context, input }) => {
const { repos, mediaRoot } = context.services.deps;
const { spaceId } = context.actor;
await repos.getProjectRecord(spaceId, input.projectId);
const renders = await repos.listRenders(spaceId, input.projectId);
await repos.removeProject(spaceId, input.projectId);
for (const render of renders)
await removeMediaPath(mediaRoot, `${spaceId}/renders/${render.id}`);
return { ok: true as const };
}),
autoPlan: media.projects.autoPlan.handler(async ({ context, input }) => {
await context.services.deps.repos.getProjectRecord(context.actor.spaceId, input.projectId);
await knownProfiles(context.services, input.profileIds);
return context.services.marketing.autoPlan(context.actor, input);
}),
},
plans: {
submit: media.plans.submit.handler(async ({ context, input }) => {
await context.services.deps.repos.getProjectRecord(context.actor.spaceId, input.projectId);
return context.services.marketing.submitPlan(context.actor, {
projectId: input.projectId,
edl: input.edl,
rationale: input.rationale,
createdBy: "user",
});
}),
},
renders: {
start: media.renders.start.handler(async ({ context, input }) => {
await context.services.deps.repos.getPlan(context.actor.spaceId, input.planId);
return context.services.render.start(context.actor, input);
}),
get: media.renders.get.handler(({ context, input }) =>
context.services.deps.repos.getRender(context.actor.spaceId, input.renderId),
),
cancel: media.renders.cancel.handler(async ({ context, input }) => {
await context.services.deps.repos.getRender(context.actor.spaceId, input.renderId);
return context.services.render.cancel(context.actor, input.renderId);
}),
thumbnails: media.renders.thumbnails.handler(async ({ context, input }) => {
await context.services.deps.repos.getRender(context.actor.spaceId, input.renderId);
return context.services.marketing.thumbnails(context.actor, input);
}),
},
marketing: {
copy: media.marketing.copy.handler(async ({ context, input }) => {
await context.services.deps.repos.getRender(context.actor.spaceId, input.renderId);
return context.services.marketing.copy(context.actor, input);
}),
},
publications: {
list: media.publications.list.handler(({ context, input }) =>
context.services.deps.repos.listPublications(context.actor.spaceId, input),
),
create: media.publications.create.handler(async ({ context, input }) => {
await context.services.deps.repos.getRender(context.actor.spaceId, input.renderId);
return context.services.marketing.createPublication(context.actor, input);
}),
bundle: media.publications.bundle.handler(async ({ context, input }) => {
await context.services.deps.repos.getPublication(
context.actor.spaceId,
input.publicationId,
);
return context.services.marketing.bundle(context.actor, input.publicationId);
}),
markPublished: media.publications.markPublished.handler(({ context, input }) =>
context.services.deps.repos.markPublicationPublished(
context.actor.spaceId,
input.publicationId,
input.externalUrl,
context.services.deps.now(),
),
),
cancel: media.publications.cancel.handler(async ({ context, input }) => {
await context.services.deps.repos.getPublication(
context.actor.spaceId,
input.publicationId,
);
return context.services.marketing.cancelPublication(context.actor, input.publicationId);
}),
},
metrics: {
mappings: media.metrics.mappings.handler(({ context }) =>
context.services.deps.repos.listMetricMappings(context.actor.spaceId),
),
saveMapping: media.metrics.saveMapping.handler(({ context, input }) =>
context.services.deps.repos.saveMetricMapping(context.actor.spaceId, input),
),
importCsv: media.metrics.importCsv.handler(async ({ context, input }) => {
await context.services.deps.repos.getMetricMapping(context.actor.spaceId, input.mappingId);
return context.services.marketing.importMetricsCsv(context.actor, input);
}),
record: media.metrics.record.handler(({ context, input }) =>
recordMetric(context.services.deps, context.actor, input),
),
funnel: media.metrics.funnel.handler(({ context, input }) =>
context.services.deps.repos.funnel(context.actor.spaceId, input.projectId),
),
segments: media.metrics.segments.handler(({ context, input }) =>
segmentFunnel(context.services.deps, context.actor, input.projectId),
),
resetTeaserModel: media.metrics.resetTeaserModel.handler(async ({ context }) => {
await resetTeaserModel(context.services.deps, context.actor);
return { ok: true as const };
}),
},
});
}

View File

@ -1,116 +0,0 @@
import { describe, expect, it } from "vitest";
import {
closingNote,
focusCard,
focusFactValue,
intakeContext,
intakeStep,
nextIntakeStep,
readIntakeAnswer,
secondaryOption,
} from "./onboarding-intake.js";
// Intl may separate the time from AM/PM with a narrow no-break space.
const plain = (text: string) => text.replace(/\s/g, " ");
const off = { morning: null, evening: null, weekly: null };
describe("onboarding steps", () => {
it("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");
expect(nextIntakeStep("rhythm", { canEnablePush: false })?.id).toBe("quiet_hours");
expect(nextIntakeStep("quiet_hours", { canEnablePush: true })?.id).toBe("notifications");
expect(nextIntakeStep("quiet_hours", { canEnablePush: false })).toBeNull();
expect(nextIntakeStep("notifications", { canEnablePush: true })).toBeNull();
});
it("gives every onboarding card a quiet way out, and another bot's focus card none", () => {
const context = intakeContext(null, new Date());
for (const id of ["focus", "goal", "rhythm", "quiet_hours", "notifications"] as const) {
const card = intakeStep(id).card(context);
expect(card.step).toBe(id);
expect(secondaryOption(card)).toBeDefined();
}
expect(focusCard().step).toBeUndefined();
expect(secondaryOption(focusCard())).toBeUndefined();
});
it("writes quiet hours in the user's locale", () => {
const labels = (locale: string | null) =>
intakeStep("quiet_hours")
.card(intakeContext({ locale, timeZone: null }, new Date()))
.options.map((option) => plain(option.label));
expect(labels("de-AT")).toEqual(["22:00 – 7:00", "23:00 – 8:00", "Skip"]);
expect(labels("en-US")).toEqual(["10:00 PM – 7:00 AM", "11:00 PM – 8:00 AM", "Skip"]);
expect(labels("not a tag")).toEqual(labels("en"));
});
});
describe("readIntakeAnswer", () => {
const goal = intakeStep("goal").card(intakeContext(null, new Date()));
const rhythm = intakeStep("rhythm").card(intakeContext(null, new Date()));
it("accepts the card's own options and words only where the card takes them", () => {
expect(readIntakeAnswer(rhythm, "weekly", undefined)).toEqual({
option: { id: "weekly", letter: "C", label: "Once a week" },
});
expect(readIntakeAnswer(rhythm, "skip", undefined)).toBeNull();
expect(readIntakeAnswer(rhythm, "_text", "Mornings")).toBeNull();
expect(readIntakeAnswer(goal, "_text", " Run a 10K ")).toEqual({ text: "Run a 10K" });
expect(readIntakeAnswer(goal, "_text", " ")).toBeNull();
expect(readIntakeAnswer(goal, "skip", undefined)?.option?.label).toBe("Skip");
});
});
describe("closingNote", () => {
const vienna = (iso: string, locale = "de-AT") =>
intakeContext({ locale, timeZone: "Europe/Vienna" }, new Date(iso));
const note = (rhythm: Parameters<typeof closingNote>[0], context: ReturnType<typeof vienna>) => {
const { nextCheckInAt, text } = closingNote(rhythm, context);
return { nextCheckInAt, text: plain(text) };
};
it("names the first check-in in the user's time zone", () => {
const morning = { ...off, morning: "08:00" };
expect(note(morning, vienna("2026-10-07T04:00:00Z"))).toEqual({
nextCheckInAt: "2026-10-07T06:00:00.000Z",
text: "All set. I’ll check in today at 8:00.",
});
expect(note(morning, vienna("2026-10-07T09:00:00Z"))).toEqual({
nextCheckInAt: "2026-10-08T06:00:00.000Z",
text: "All set. I’ll check in tomorrow at 8:00.",
});
expect(note({ ...morning, evening: "18:00" }, vienna("2026-10-07T09:00:00Z")).text).toBe(
"All set. I’ll check in today at 18:00.",
);
// Wednesday in Vienna; the weekly review is on Sunday.
const weekly = { ...off, weekly: { day: 0, time: "18:00" } };
expect(note(weekly, vienna("2026-10-07T09:00:00Z", "en-US"))).toEqual({
nextCheckInAt: "2026-10-11T16:00:00.000Z",
text: "All set. I’ll check in on Sunday at 6:00 PM.",
});
});
it("says the user can just write when no check-in is set", () => {
expect(closingNote(off, vienna("2026-10-07T09:00:00Z"))).toEqual({
kind: "intake_note",
note: "done",
nextCheckInAt: null,
text: "All set. I’m here whenever you need me.",
});
});
it("falls back to English and UTC for an unusable profile", () => {
expect(intakeContext({ locale: "??", timeZone: "Mars/Olympus" }, new Date(0))).toMatchObject({
locale: "en",
timeZone: "UTC",
});
});
});

View File

@ -1,326 +0,0 @@
import type { JobPublisher } from "@rakazo/adapter-kit";
import { COACH_KEYS, coachCron, setCoachRhythm } from "@rakazo/adapters";
import type { CoachRhythm, MessageBlock, OnboardingStep, QuietHours } from "@rakazo/contracts";
import { CHOICE_TEXT_ANSWER_ID } from "@rakazo/contracts";
import { knownTimeZone, nextCronDateAcross } from "@rakazo/core";
import type { Prisma, PrismaClient } from "@rakazo/db";
import { createGoal, updateNotificationSettings, updateUserProfile } from "@rakazo/db";
/**
* The questions the main bot asks a new user in the main conversation, one card at a time.
* Each step is a card plus what its answer changes; the order of `INTAKE_STEPS` is the order
* the user sees. Everything here is deterministic, so no model tokens are spent.
*/
export type ChoiceBlock = Extract<MessageBlock, { kind: "choice" }>;
type ChoiceOption = ChoiceBlock["options"][number];
/** A validated answer: an option of the card, or the user's own words. */
export type IntakeAnswer =
| { option: ChoiceOption; text?: undefined }
| { option?: undefined; text: string };
/** What a card may adapt to: times are written in the user's locale. */
export interface IntakeContext {
locale: string;
timeZone: string;
now: Date;
}
export interface IntakeEffects {
prisma: PrismaClient;
jobs: JobPublisher;
tx: Prisma.TransactionClient;
userId: string;
}
interface IntakeStep {
id: OnboardingStep;
/** Asked only when this holds; otherwise onboarding moves past the step. */
asks?: (device: { canEnablePush: boolean }) => boolean;
card: (context: IntakeContext) => ChoiceBlock;
/** Records a validated answer. Runs while the main conversation is locked. */
save: (effects: IntakeEffects, answer: IntakeAnswer) => Promise<void>;
}
export type FocusOptionId = "day" | "inbox" | "research" | "crew";
export interface FocusOption {
id: FocusOptionId;
letter: string;
label: string;
apps: 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[] = [
{
id: "day",
letter: "A",
label: "Day-to-day work",
apps: ["slack", "gmail", "googlecalendar"],
},
{
id: "inbox",
letter: "B",
label: "Inbox & email",
apps: ["gmail", "googlecalendar", "slack"],
},
{
id: "research",
letter: "C",
label: "Research & writing",
apps: ["hackernews", "notion", "googledocs"],
},
{
id: "crew",
letter: "D",
label: "Specialist crew",
apps: ["slack", "gmail", "googlecalendar"],
},
];
const SKIP: ChoiceOption = { id: "skip", letter: "", label: "Skip" };
/** The focus card. Onboarding adds a skip; another bot's card is dismissed instead. */
export function focusCard(step?: "focus"): ChoiceBlock {
return {
kind: "choice",
question: "What do you want me on first?",
options: [
...FOCUS_OPTIONS.map(({ id, letter, label }) => ({ id, letter, label })),
...(step ? [SKIP] : []),
],
...(step ? { step } : {}),
};
}
const RHYTHMS: Record<string, CoachRhythm> = {
morning: { morning: "08:00", evening: null, weekly: null },
morning_evening: { morning: "08:00", evening: "18:00", weekly: null },
weekly: { morning: null, evening: null, weekly: { day: 0, time: "18:00" } },
none: { morning: null, evening: null, weekly: null },
};
const QUIET_HOURS: Record<string, QuietHours> = {
"22-07": { start: "22:00", end: "07:00" },
"23-08": { start: "23:00", end: "08:00" },
};
export const INTAKE_STEPS: IntakeStep[] = [
{
id: "focus",
card: () => focusCard("focus"),
save: async ({ prisma, tx, userId }, answer) => {
const option = FOCUS_OPTIONS.find((entry) => entry.id === answer.option?.id);
if (!option) return;
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: focusFactValue(option.id, locale), source: "onboarding" },
],
});
},
},
{
id: "goal",
card: () => ({
kind: "choice",
step: "goal",
question: "What’s one thing you want to get done in the next few weeks?",
textInput: { placeholder: "Your goal" },
options: [SKIP],
}),
save: async ({ prisma, userId }, answer) => {
if (answer.text)
await createGoal(prisma, userId, { title: answer.text, source: "onboarding" });
},
},
{
id: "rhythm",
card: () => ({
kind: "choice",
step: "rhythm",
question: "When should I check in with you?",
options: [
{ id: "morning", letter: "A", label: "Every morning" },
{ id: "morning_evening", letter: "B", label: "Morning and evening" },
{ id: "weekly", letter: "C", label: "Once a week" },
{ id: "none", letter: "", label: "Not now" },
],
}),
save: async ({ prisma, jobs, userId }, answer) => {
const rhythm = answer.option && RHYTHMS[answer.option.id];
if (rhythm) await setCoachRhythm({ prisma, jobs }, { userId, rhythm });
},
},
{
id: "quiet_hours",
card: (context) => ({
kind: "choice",
step: "quiet_hours",
question: "When should I stay quiet?",
options: [
...Object.entries(QUIET_HOURS).map(([id, range], index) => ({
id,
letter: String.fromCharCode(65 + index),
label: `${formatClock(range.start, context.locale)} – ${formatClock(range.end, context.locale)}`,
})),
SKIP,
],
}),
save: async ({ prisma, userId }, answer) => {
const quietHours = answer.option && QUIET_HOURS[answer.option.id];
if (quietHours) await updateNotificationSettings(prisma, userId, { quietHours });
},
},
{
// The browser subscribes when the user taps "Turn on"; the answer only moves on.
id: "notifications",
asks: ({ canEnablePush }) => canEnablePush,
card: () => ({
kind: "choice",
step: "notifications",
question: "Can I send you notifications?",
options: [
{ id: "on", letter: "A", label: "Turn on" },
{ id: "later", letter: "", label: "Not now" },
],
}),
save: async () => {},
},
];
export function intakeStep(id: OnboardingStep): IntakeStep {
const step = INTAKE_STEPS.find((entry) => entry.id === id);
if (!step) throw new Error(`Unknown onboarding step: ${id}`);
return step;
}
/** The step after `id` that applies to this device, or null when onboarding is done. */
export function nextIntakeStep(
id: OnboardingStep,
device: { canEnablePush: boolean },
): IntakeStep | null {
const index = INTAKE_STEPS.findIndex((entry) => entry.id === id);
return INTAKE_STEPS.slice(index + 1).find((entry) => entry.asks?.(device) ?? true) ?? null;
}
/** Checks an answer against the card it answers; null when the card does not offer it. */
export function readIntakeAnswer(
card: ChoiceBlock,
optionId: string,
text: string | undefined,
): IntakeAnswer | null {
if (optionId === CHOICE_TEXT_ANSWER_ID) {
const words = text?.trim();
return card.textInput && words ? { text: words } : null;
}
const option = card.options.find((entry) => entry.id === optionId);
return option ? { option } : null;
}
/** The secondary answer ("Skip", "Not now") a dismissed card counts as. */
export function secondaryOption(card: ChoiceBlock): ChoiceOption | undefined {
return card.options.find((option) => !option.letter);
}
/** Card times and the closing line follow the user's profile, falling back to English and UTC. */
export function intakeContext(
profile: { locale: string | null; timeZone: string | null } | null,
now: Date,
): IntakeContext {
let locale = "en";
try {
if (profile?.locale) locale = Intl.getCanonicalLocales(profile.locale)[0] ?? "en";
} catch {
// Not a language tag; keep English.
}
return { locale, timeZone: knownTimeZone(profile?.timeZone) ?? "UTC", now };
}
function formatClock(time: string, locale: string): string {
const [hour = 0, minute = 0] = time.split(":").map(Number);
return new Intl.DateTimeFormat(locale, {
hour: "numeric",
minute: "2-digit",
timeZone: "UTC",
}).format(new Date(Date.UTC(2000, 0, 1, hour, minute)));
}
function localDay(date: Date, timeZone: string): number {
const [year, month, day] = new Intl.DateTimeFormat("en-CA", {
timeZone,
year: "numeric",
month: "2-digit",
day: "2-digit",
})
.format(date)
.split("-")
.map(Number);
return Date.UTC(year ?? 1970, (month ?? 1) - 1, day ?? 1) / 86_400_000;
}
export type IntakeNote = Extract<MessageBlock, { kind: "intake_note" }>;
/**
* The closing note: what happens next, i.e. the first check-in when there is one. The web says
* it in the user's language from `nextCheckInAt`; `text` is the English line for everything else.
*/
export function closingNote(rhythm: CoachRhythm, context: IntakeContext): IntakeNote {
const crons = COACH_KEYS.flatMap((key) => coachCron(key, rhythm) ?? []);
const next = nextCronDateAcross(crons, context.now, context.timeZone);
return {
kind: "intake_note",
note: "done",
nextCheckInAt: next?.toISOString() ?? null,
text: closingText(next, context),
};
}
function closingText(next: Date | null, context: IntakeContext): string {
if (!next) return "All set. I’m here whenever you need me.";
const days = localDay(next, context.timeZone) - localDay(context.now, context.timeZone);
const day =
days === 0
? "today"
: days === 1
? "tomorrow"
: `on ${new Intl.DateTimeFormat("en-US", { timeZone: context.timeZone, weekday: "long" }).format(next)}`;
const time = new Intl.DateTimeFormat(context.locale, {
hour: "numeric",
minute: "2-digit",
timeZone: context.timeZone,
}).format(next);
return `All set. I’ll check in ${day} at ${time}.`;
}

View File

@ -1,192 +1,166 @@
import { mkdir, mkdtemp, realpath, rm, symlink, writeFile } from "node:fs/promises"; import { mkdir, mkdtemp, realpath, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os"; import { tmpdir } from "node:os";
import path from "node:path"; import { join } from "node:path";
import type { PrismaClient } from "@rakazo/db"; import type * as db from "@rakazo/db";
import { afterAll, describe, expect, it, vi } from "vitest"; import { SpaceLimitError } from "@rakazo/db";
import { import { afterEach, describe, expect, it, vi } from "vitest";
applyOnboardingScan, import { getScanStatus, startOnboardingScan } from "./onboarding-scan.js";
discoverOnboardingScan,
onboardingScanFolder, const createSpaceForMember = vi.hoisted(() => vi.fn());
onboardingScanStatus, const ensureMainConversation = vi.hoisted(() => vi.fn());
startOnboardingScan, vi.mock("@rakazo/db", async (original) => ({
} from "./onboarding-scan.js"; ...(await original<typeof db>()),
createSpaceForMember,
ensureMainConversation,
}));
const owner = { userId: "user-owner", spaceId: "space-owner", isDeploymentOwner: true }; const owner = { userId: "user-owner", spaceId: "space-owner", isDeploymentOwner: true };
const member = { userId: "user-member", spaceId: "space-member", isDeploymentOwner: false }; const member = { userId: "user-member", spaceId: "space-member", isDeploymentOwner: false };
const roots: string[] = [];
/** Fails the test if the scan writes anything before its checks pass. */ type SpaceUpdate = { where: { id: string }; data: { workspacePath: string } };
const untouchedPrisma = new Proxy(
{}, /** The spaces the scan reads and links; new ones come from `createSpaceForMember`. */
{ function fakeDatabase({ spaceLimit = Number.POSITIVE_INFINITY } = {}) {
get() { const spaces: Array<{ id: string; name: string; workspacePath: string | null }> = [];
throw new Error("the database must not be touched"); createSpaceForMember.mockImplementation(async (_prisma: unknown, input: { name: string }) => {
if (spaces.length >= spaceLimit) throw new SpaceLimitError();
const space = { id: `space-${spaces.length + 1}`, name: input.name, workspacePath: null };
spaces.push(space);
return { id: space.id, name: space.name };
});
const prisma = {
space: {
findMany: async () => spaces.filter((space) => space.workspacePath),
update: async ({ where, data }: SpaceUpdate) => {
const space = spaces.find((candidate) => candidate.id === where.id);
if (space) space.workspacePath = data.workspacePath;
},
}, },
}, } as unknown as db.PrismaClient;
) as PrismaClient; return { prisma, spaces };
async function tempDir(): Promise<string> {
const dir = await realpath(await mkdtemp(path.join(tmpdir(), "evoo-scan-")));
roots.push(dir);
return dir;
} }
async function files(root: string, entries: string[]): Promise<void> { async function scanHomeWithOneFile(): Promise<string> {
for (const entry of entries) { const home = await mkdtemp(join(tmpdir(), "evoo-scan-"));
const file = path.join(root, entry); await mkdir(join(home, "Projects"));
await mkdir(path.dirname(file), { recursive: true }); await writeFile(join(home, "Projects", "notes.ts"), "export const x = 1;\n");
await writeFile(file, "x\n"); return home;
}
} }
async function project(root: string, relative: string, entries: string[]): Promise<void> { async function scanHomeWithRepos(...repos: string[]): Promise<string> {
await mkdir(path.join(root, relative, ".git"), { recursive: true }); const home = await scanHomeWithOneFile();
await files(path.join(root, relative), entries); for (const repo of repos) await mkdir(join(home, repo, ".git"), { recursive: true });
return home;
} }
async function completed(scanId: string, userId: string) { async function completed(scanId: string, userId: string) {
return await vi.waitFor( return await vi.waitFor(
() => { async () => {
const status = onboardingScanStatus(scanId, userId); const status = await getScanStatus(scanId, userId);
if (status.status === "scanning") throw new Error("pending"); if (status.status !== "complete" && status.status !== "failed") throw new Error("pending");
return status; return status;
}, },
{ timeout: 10_000, interval: 20 }, { timeout: 10_000, interval: 50 },
); );
} }
afterAll(async () => { /** The emulated runtime that the shared test setup pins keeps the scan simulated. */
await Promise.all(roots.map((root) => rm(root, { recursive: true, force: true }))); function allowHostScan() {
}); vi.stubEnv("AGENT_RUNTIME", "");
vi.stubEnv("SANDBOX_PROVIDER", "");
}
describe("onboarding scan", () => { describe("onboarding scan", () => {
it("lists git projects with their languages and tools and never follows a symlink", async () => { afterEach(() => {
const home = await tempDir(); vi.unstubAllEnvs();
const outside = await tempDir(); createSpaceForMember.mockReset();
// A home folder tracked by git is not itself a project. ensureMainConversation.mockReset();
await mkdir(path.join(home, ".git"));
await project(home, "code/atlas", [
"src/a.ts",
"src/b.ts",
"src/c.ts",
"src/d.tsx",
"scripts/build.js",
"Dockerfile",
".github/workflows/ci.yml",
"infra/main.tf",
"src/odd.constructor",
"node_modules/dep/index.js",
"node_modules/dep/more.js",
]);
await project(home, "code/garden", ["main.py"]);
await project(home, "deep/a/b/edge", ["tool.py"]);
await project(home, "deep/a/b/c/too-deep", ["main.go"]);
await project(home, ".hidden/secret", ["main.go"]);
await project(home, "notes/node_modules/vendored", ["main.go"]);
await files(home, ["notes/todo.md"]);
await project(outside, "escaped", ["main.rs"]);
await symlink(path.join(outside, "escaped"), path.join(home, "linked"));
await symlink(outside, path.join(home, "code/atlas/src/outside"));
const findings = await discoverOnboardingScan(home);
expect(findings.projects).toEqual([
{
id: "project-0",
name: "atlas",
path: "code/atlas",
languages: ["TypeScript", "JavaScript"],
tools: ["Docker", "GitHub Actions", "Terraform"],
},
{ id: "project-1", name: "garden", path: "code/garden", languages: ["Python"], tools: [] },
{ id: "project-2", name: "edge", path: "deep/a/b/edge", languages: ["Python"], tools: [] },
]);
expect(findings.facts).toEqual([
{ id: "language:Python", label: "Python", category: "work", value: "Codes in Python" },
{
id: "language:JavaScript",
label: "JavaScript",
category: "work",
value: "Codes in JavaScript",
},
{
id: "language:TypeScript",
label: "TypeScript",
category: "work",
value: "Codes in TypeScript",
},
{ id: "tool:Docker", label: "Docker", category: "tools", value: "Uses Docker" },
{
id: "tool:GitHub Actions",
label: "GitHub Actions",
category: "tools",
value: "Uses GitHub Actions",
},
{ id: "tool:Terraform", label: "Terraform", category: "tools", value: "Uses Terraform" },
]);
}); });
it("offers a scan folder only to the deployment owner and only when it exists", async () => { it("hides a scan from every account except the one that started it", async () => {
const home = await tempDir(); const { prisma } = fakeDatabase();
await files(home, ["file.txt"]); const { scanId } = await startOnboardingScan({ actor: member }, prisma);
await expect(onboardingScanFolder(member, home)).resolves.toBeNull(); await expect(getScanStatus(scanId, owner.userId)).rejects.toThrow(/Scan not found/);
await expect(onboardingScanFolder(owner, undefined)).resolves.toBeNull(); await expect(getScanStatus(scanId, member.userId)).resolves.toMatchObject({
await expect(onboardingScanFolder(owner, path.join(home, "missing"))).resolves.toBeNull(); status: expect.any(String),
await expect(onboardingScanFolder(owner, path.join(home, "file.txt"))).resolves.toBeNull(); });
await expect(onboardingScanFolder(owner, home)).resolves.toBe(home); await expect(getScanStatus("scan_missing", member.userId)).rejects.toThrow(/Scan not found/);
await expect(startOnboardingScan(member, home)).rejects.toMatchObject({ code: "FORBIDDEN" });
}); });
it("keeps a scan with the account that started it and returns only relative paths", async () => { it("never reads the host without a configured scan home", async () => {
const home = await tempDir(); const { prisma } = fakeDatabase();
await project(home, "code/atlas", ["index.ts"]); const { scanId } = await startOnboardingScan({ actor: owner }, prisma);
const { scanId } = await startOnboardingScan(owner, home);
const status = await completed(scanId, owner.userId); const status = await completed(scanId, owner.userId);
expect(status).toEqual({ expect(status.status).toBe("complete");
status: "complete", expect(status.result).toMatchObject({ localRepos: [], fileSummary: { totalFiles: 0 } });
projects: [{ id: "project-0", name: "atlas", path: "code/atlas" }],
facts: [{ id: "language:TypeScript", label: "TypeScript" }],
});
expect(JSON.stringify(status)).not.toContain(home);
expect(() => onboardingScanStatus(scanId, member.userId)).toThrow(
expect.objectContaining({ code: "NOT_FOUND" }),
);
expect(() => onboardingScanStatus("missing-scan", owner.userId)).toThrow(
expect.objectContaining({ code: "NOT_FOUND" }),
);
await expect(
applyOnboardingScan(untouchedPrisma, member, home, {
scanId,
projectIds: ["project-0"],
factIds: [],
}),
).rejects.toMatchObject({ code: "NOT_FOUND" });
}); });
it("checks ownership, the folder and every chosen project again before writing", async () => { it("lets only the deployment owner discover the configured scan home", async () => {
const home = await tempDir(); allowHostScan();
const outside = await tempDir(); const scanHome = await scanHomeWithRepos("Projects/app");
await project(home, "code/atlas", ["index.ts"]); const { prisma } = fakeDatabase();
await project(home, "code/garden", ["main.py"]);
await project(outside, "elsewhere", ["main.rs"]);
const { scanId } = await startOnboardingScan(owner, home);
await completed(scanId, owner.userId);
const choice = { scanId, projectIds: ["project-1"], factIds: ["language:Python"] };
await expect( const asMember = await startOnboardingScan({ actor: member, scanHome }, prisma);
applyOnboardingScan(untouchedPrisma, { ...owner, isDeploymentOwner: false }, home, choice), const memberStatus = await completed(asMember.scanId, member.userId);
).rejects.toMatchObject({ code: "FORBIDDEN" }); expect(memberStatus.result).toMatchObject({ fileSummary: { totalFiles: 0 } });
await expect( expect(createSpaceForMember).not.toHaveBeenCalled();
applyOnboardingScan(untouchedPrisma, owner, outside, choice),
).rejects.toMatchObject({ code: "FORBIDDEN" });
// The chosen project became a symlink out of the scan folder after the scan. const asOwner = await startOnboardingScan({ actor: owner, scanHome }, prisma);
await rm(path.join(home, "code/garden"), { recursive: true }); const ownerStatus = await completed(asOwner.scanId, owner.userId);
await symlink(path.join(outside, "elsewhere"), path.join(home, "code/garden")); expect(ownerStatus.status).toBe("complete");
await expect(applyOnboardingScan(untouchedPrisma, owner, home, choice)).rejects.toMatchObject({ expect(ownerStatus.result).toMatchObject({ fileSummary: { totalFiles: 1 } });
code: "BAD_REQUEST", });
message: "That folder does not exist or is not readable.",
it("links each repository of the deployment owner to one project of its own", async () => {
allowHostScan();
const scanHome = await scanHomeWithRepos("Projects/app", ".config/nvim");
const app = await realpath(join(scanHome, "Projects", "app"));
const { prisma, spaces } = fakeDatabase();
const first = await startOnboardingScan({ actor: owner, scanHome }, prisma);
const firstStatus = await completed(first.scanId, owner.userId);
expect(firstStatus.status).toBe("complete");
expect(firstStatus.result).toMatchObject({
localRepos: [{ name: "app" }],
createdProjects: [{ spaceId: "space-1", name: "app", path: app }],
}); });
expect(createSpaceForMember).toHaveBeenCalledWith(prisma, {
currentSpaceId: owner.spaceId,
userId: owner.userId,
name: "app",
});
expect(spaces).toEqual([{ id: "space-1", name: "app", workspacePath: app }]);
// Each new project starts with its own main agent.
expect(ensureMainConversation).toHaveBeenCalledWith(prisma, owner.userId, "space-1");
// Scanning again finds the linked project instead of creating a second one.
const again = await startOnboardingScan({ actor: owner, scanHome }, prisma);
const againStatus = await completed(again.scanId, owner.userId);
expect(againStatus.result).toMatchObject({ createdProjects: [] });
expect(createSpaceForMember).toHaveBeenCalledTimes(1);
});
it("joins a running scan instead of linking its repositories twice", async () => {
allowHostScan();
const scanHome = await scanHomeWithRepos("Projects/app");
const { prisma, spaces } = fakeDatabase();
const first = await startOnboardingScan({ actor: owner, scanHome }, prisma);
const second = await startOnboardingScan({ actor: owner, scanHome }, prisma);
expect(second.scanId).toBe(first.scanId);
await completed(first.scanId, owner.userId);
expect(spaces).toHaveLength(1);
});
it("stops creating projects at the space limit and still completes", async () => {
allowHostScan();
const scanHome = await scanHomeWithRepos("Projects/a", "Projects/b", "Projects/c");
const { prisma } = fakeDatabase({ spaceLimit: 1 });
const { scanId } = await startOnboardingScan({ actor: owner, scanHome }, prisma);
const status = await completed(scanId, owner.userId);
expect(status.status).toBe("complete");
expect(status.result).toMatchObject({ createdProjects: [{ spaceId: "space-1" }] });
expect(createSpaceForMember).toHaveBeenCalledTimes(2);
}); });
}); });

File diff suppressed because it is too large Load Diff

View File

@ -1,7 +1,6 @@
import type * as db from "@rakazo/db"; import type * as db from "@rakazo/db";
import { describe, expect, it, vi } from "vitest"; import { describe, expect, it, vi } from "vitest";
import { chooseFocus, markAppConnected } from "./onboarding.js"; import { chooseFocus, markAppConnected } from "./onboarding.js";
import { focusCard } from "./onboarding-intake.js";
const posted = vi.hoisted(() => [] as Array<{ blocks: unknown[] }>); const posted = vi.hoisted(() => [] as Array<{ blocks: unknown[] }>);
vi.mock("@rakazo/db", async (original) => ({ vi.mock("@rakazo/db", async (original) => ({
@ -14,16 +13,16 @@ vi.mock("@rakazo/db", async (original) => ({
})); }));
function fixture(catalog: unknown[]) { function fixture(catalog: unknown[]) {
posted.length = 0; posted.length = 0;
// A specialist's focus card, waiting for an answer.
const pending = vi.fn(async () => [{ id: "choice", blocks: [focusCard()] }]);
const tx = { const tx = {
$executeRaw: vi.fn(), $executeRaw: vi.fn(),
message: { findMany: pending, update: vi.fn() }, message: {
findMany: vi.fn(async () => [{ id: "choice", blocks: [{ kind: "choice", answerId: null }] }]),
update: vi.fn(),
},
}; };
const deps = { const deps = {
prisma: { prisma: {
bot: { findFirst: vi.fn(async () => ({ id: "bot", thread: { id: "thread" } })) }, bot: { findFirst: vi.fn(async () => ({ id: "bot", thread: { id: "thread" } })) },
message: { findMany: pending },
$transaction: vi.fn(async (fn: (tx: unknown) => unknown) => fn(tx)), $transaction: vi.fn(async (fn: (tx: unknown) => unknown) => fn(tx)),
}, },
events: { notify: vi.fn() }, events: { notify: vi.fn() },
@ -40,16 +39,17 @@ function fixture(catalog: unknown[]) {
describe("onboarding connection suggestions", () => { describe("onboarding connection suggestions", () => {
it("does not invent authorization cards when no connector has an app catalog", async () => { it("does not invent authorization cards when no connector has an app catalog", async () => {
const { deps, actor } = fixture([]); const { deps, actor } = fixture([]);
await chooseFocus(deps, actor, "bot", { optionId: "day" }); await chooseFocus(deps, actor, "bot", "day");
// The answered card is all that changes; nothing is said around it. expect(posted.flatMap((message) => message.blocks)).not.toContainEqual(
expect(posted).toEqual([]); expect.objectContaining({ kind: "app_connect" }),
expect(deps.events.notify).toHaveBeenCalled(); );
expect(posted.length).toBeGreaterThan(0);
}); });
it("uses the available connector and omits unavailable apps", async () => { it("uses the available connector and omits unavailable apps", async () => {
const { deps, actor } = fixture([ const { deps, actor } = fixture([
{ connectorId: "pipedream", slug: "slack", name: "Slack", connected: false, logo: null }, { connectorId: "pipedream", slug: "slack", name: "Slack", connected: false, logo: null },
]); ]);
await chooseFocus(deps, actor, "bot", { optionId: "day" }); await chooseFocus(deps, actor, "bot", "day");
expect( expect(
posted posted
.flatMap((message) => message.blocks) .flatMap((message) => message.blocks)
@ -57,8 +57,6 @@ describe("onboarding connection suggestions", () => {
).toEqual([ ).toEqual([
expect.objectContaining({ connectorId: "pipedream", provider: "slack", name: "Slack" }), expect.objectContaining({ connectorId: "pipedream", provider: "slack", name: "Slack" }),
]); ]);
// The cards speak for themselves.
expect(posted).toHaveLength(1);
}); });
}); });

View File

@ -1,47 +1,67 @@
import { ORPCError } from "@orpc/server";
import type { JobPublisher } from "@rakazo/adapter-kit";
import type { ConnectorRegistry } from "@rakazo/adapters"; import type { ConnectorRegistry } from "@rakazo/adapters";
import { getCoachRhythm } from "@rakazo/adapters"; import type { Actor, MessageBlock } from "@rakazo/contracts";
import type { Actor, MessageBlock, OnboardingStep } from "@rakazo/contracts";
import { CHOICE_TEXT_ANSWER_ID } from "@rakazo/contracts";
import { featuredConnectorProvidersMatch } from "@rakazo/core"; import { featuredConnectorProvidersMatch } from "@rakazo/core";
import { import {
appendEventInTransaction, appendEventInTransaction,
createThreadMessageInTransaction, createThreadMessageInTransaction,
ensureMainConversation, ensureMainConversation,
IsolationError, IsolationError,
isPersonalMainBot,
type Prisma, type Prisma,
type PrismaClient, type PrismaClient,
type ThreadEvents, type ThreadEvents,
} from "@rakazo/db"; } from "@rakazo/db";
import type { ChoiceBlock, FocusOption, IntakeAnswer, IntakeContext } from "./onboarding-intake.js";
import {
closingNote,
FOCUS_OPTIONS,
focusCard,
intakeContext,
intakeStep,
nextIntakeStep,
readIntakeAnswer,
secondaryOption,
} from "./onboarding-intake.js";
/** /**
* Conversational onboarding, posted deterministically into a bot's thread; no model tokens are * First-run conversational onboarding, seeded deterministically into the bot's
* spent. The main bot asks the onboarding questions (`onboarding-intake.ts`) in the main * thread: greeting, a focus choice, and available app cards the user authorizes
* conversation, one card after each answer. Any other bot gets one focus card that suggests * inline. Focus must not rename the bot. No model tokens are spent. The main bot
* apps the user authorizes inline. Focus never renames a bot. * talks in the main conversation, where onboarding lands.
*/ */
type OnboardingDeps = { type OnboardingDeps = {
prisma: PrismaClient; prisma: PrismaClient;
events: ThreadEvents; events: ThreadEvents;
connectors: ConnectorRegistry; connectors: ConnectorRegistry;
jobs: JobPublisher;
}; };
type Target = { spaceId: string; botId: string; threadId: string }; type FocusOption = {
id: string;
letter: string;
label: string;
summary: string;
apps: string[];
};
const FOCUS_OPTIONS: FocusOption[] = [
{
id: "day",
letter: "A",
label: "Day-to-day work",
summary: "Slack, calendar, and email",
apps: ["slack", "gmail", "googlecalendar"],
},
{
id: "inbox",
letter: "B",
label: "Inbox & email",
summary: "email and calendar",
apps: ["gmail", "googlecalendar", "slack"],
},
{
id: "research",
letter: "C",
label: "Research & writing",
summary: "the web, notes, and docs",
apps: ["hackernews", "notion", "googledocs"],
},
{
id: "crew",
letter: "D",
label: "Specialist crew",
summary: "a coordinated team of focused Oos",
apps: ["slack", "gmail", "googlecalendar"],
},
];
const APP_DESCRIPTIONS: Record<string, string> = { const APP_DESCRIPTIONS: Record<string, string> = {
slack: "Search, read, and send messages.", slack: "Search, read, and send messages.",
@ -65,102 +85,74 @@ async function requireBotThread(deps: OnboardingDeps, actor: Actor, botId: strin
return { bot, thread: bot.thread }; return { bot, thread: bot.thread };
} }
/** async function post(
* Serializes card writes on a thread. NO KEY UPDATE still lets an answer's effects insert rows deps: OnboardingDeps,
* that reference the thread from another connection (the coach routines do). target: { spaceId: string; botId: string; threadId: string },
*/
async function lockThread(tx: Prisma.TransactionClient, threadId: string): Promise<void> {
await tx.$executeRaw`SELECT id FROM threads WHERE id = ${threadId} FOR NO KEY UPDATE`;
}
async function postInTransaction(
tx: Prisma.TransactionClient,
target: Target,
blocks: MessageBlock[], blocks: MessageBlock[],
): Promise<{ seq: number }> { ): Promise<string> {
const message = await createThreadMessageInTransaction(tx, { const committed = await deps.prisma.$transaction(async (tx: Prisma.TransactionClient) => {
threadId: target.threadId, const message = await createThreadMessageInTransaction(tx, {
role: "bot", threadId: target.threadId,
blocks, role: "bot",
botId: target.botId, blocks,
}); botId: target.botId,
return appendEventInTransaction(tx, { });
spaceId: target.spaceId, const event = await appendEventInTransaction(tx, {
threadId: target.threadId, spaceId: target.spaceId,
botId: target.botId, threadId: target.threadId,
type: "thread.message.created", botId: target.botId,
payload: { messageId: message.id, role: "bot", blocks }, type: "thread.message.created",
}); payload: { messageId: message.id, role: "bot", blocks },
} });
return { message, event };
async function updateBlocksInTransaction(
tx: Prisma.TransactionClient,
target: Target,
messageId: string,
blocks: MessageBlock[],
): Promise<{ seq: number }> {
await tx.message.update({ where: { id: messageId }, data: { blocks } });
return appendEventInTransaction(tx, {
spaceId: target.spaceId,
threadId: target.threadId,
botId: target.botId,
type: "thread.message.updated",
payload: { messageId, role: "bot", blocks },
}); });
await deps.events.notify(target.threadId, committed.event.seq);
return committed.message.id;
} }
async function updateBlocks( async function updateBlocks(
deps: OnboardingDeps, deps: OnboardingDeps,
target: Target, target: { spaceId: string; botId: string; threadId: string },
messageId: string, messageId: string,
blocks: MessageBlock[], blocks: MessageBlock[],
): Promise<void> { ): Promise<void> {
const event = await deps.prisma.$transaction((tx: Prisma.TransactionClient) => const event = await deps.prisma.$transaction(async (tx: Prisma.TransactionClient) => {
updateBlocksInTransaction(tx, target, messageId, blocks), await tx.message.update({ where: { id: messageId }, data: { blocks } });
); return appendEventInTransaction(tx, {
spaceId: target.spaceId,
threadId: target.threadId,
botId: target.botId,
type: "thread.message.updated",
payload: { messageId, role: "bot", blocks },
});
});
await deps.events.notify(target.threadId, event.seq); await deps.events.notify(target.threadId, event.seq);
} }
/** Sentinel answerId for a focus card the user dismissed without choosing. */ /** Sentinel answerId for a focus card the user dismissed without choosing. */
export const FOCUS_DISMISSED_ANSWER_ID = "_dismissed"; export const FOCUS_DISMISSED_ANSWER_ID = "_dismissed";
/** Checks that the caller may onboard this bot's thread; fresh threads start empty. */
export async function startOnboarding( export async function startOnboarding(
deps: OnboardingDeps, deps: OnboardingDeps,
actor: Actor, actor: Actor,
botId: string, botId: string,
): Promise<void> { ): Promise<void> {
await requireBotThread(deps, actor, botId); const { thread } = await requireBotThread(deps, actor, botId);
const existing = await deps.prisma.message.count({ where: { threadId: thread.id } });
if (existing > 0) return;
// Fresh chats start empty (same as web). The focus card is posted later via
// promptFocus so non-first bots can wait ~10s for free typing, or skip if the
// user already engaged.
} }
function isChoice(block: MessageBlock): block is ChoiceBlock { function messageHasChoice(blocks: MessageBlock[]): boolean {
return block.kind === "choice"; return blocks.some((block) => block.kind === "choice");
} }
function pendingChoice(blocks: MessageBlock[]): ChoiceBlock | undefined { function messageHasPendingChoice(blocks: MessageBlock[]): boolean {
return blocks.find((block): block is ChoiceBlock => isChoice(block) && !block.answerId); return blocks.some((block) => block.kind === "choice" && !block.answerId);
} }
/** The newest card in the thread that still waits for an answer, however long ago it came. */
async function findPendingCard(client: PrismaClient | Prisma.TransactionClient, threadId: string) {
const messages = await client.message.findMany({
where: { threadId, role: "bot", blocks: { array_contains: [{ kind: "choice" }] } },
select: { id: true, blocks: true },
orderBy: { seq: "desc" },
take: 20,
});
for (const message of messages) {
const blocks = message.blocks as MessageBlock[];
const card = pendingChoice(blocks);
if (card) return { messageId: message.id, blocks, card };
}
return null;
}
/**
* Posts the first card once. The main bot starts the onboarding questions, one set per user;
* any other bot posts its focus card only while its thread is still idle.
*/
export async function promptFocus( export async function promptFocus(
deps: OnboardingDeps, deps: OnboardingDeps,
actor: Actor, actor: Actor,
@ -168,32 +160,44 @@ export async function promptFocus(
): Promise<void> { ): Promise<void> {
const { bot, thread } = await requireBotThread(deps, actor, botId); const { bot, thread } = await requireBotThread(deps, actor, botId);
const target = { spaceId: actor.spaceId, botId: bot.id, threadId: thread.id }; const target = { spaceId: actor.spaceId, botId: bot.id, threadId: thread.id };
// The intake runs with the personal main agent; a project's main bot starts like any other. const blocks: MessageBlock[] = [
const main = await isPersonalMainBot(deps.prisma, bot); {
// Check + insert + event in one transaction so concurrent callers cannot duplicate cards, kind: "choice",
// and a concurrent user send cannot publish first. question: "What do you want me on first?",
options: FOCUS_OPTIONS.map(({ id, letter, label }) => ({ id, letter, label })),
},
];
// Check + insert + event in one transaction so concurrent promptFocus calls
// cannot duplicate cards, and a concurrent user send cannot publish first.
const committed = await deps.prisma.$transaction(async (tx: Prisma.TransactionClient) => { const committed = await deps.prisma.$transaction(async (tx: Prisma.TransactionClient) => {
await lockThread(tx, thread.id); // Serialize concurrent promptFocus callers on this thread before the gate check.
if (main && (await tx.userOnboarding.findUnique({ where: { userId: actor.userId } }))) { await tx.$executeRaw`SELECT id FROM threads WHERE id = ${thread.id} FOR UPDATE`;
return null;
}
const recent = await tx.message.findMany({ const recent = await tx.message.findMany({
where: { threadId: thread.id }, where: { threadId: thread.id },
select: { role: true, blocks: true }, select: { role: true, blocks: true },
orderBy: { createdAt: "asc" },
}); });
if (!main && recent.some((message) => message.role === "user")) return null; if (recent.some((message) => message.role === "user")) return null;
if (recent.some((message) => (message.blocks as MessageBlock[]).some(isChoice))) return null; if (recent.some((message) => messageHasChoice(message.blocks as MessageBlock[]))) return null;
if (!main) return postInTransaction(tx, target, [focusCard()]); const message = await createThreadMessageInTransaction(tx, {
const first = intakeStep("focus"); threadId: target.threadId,
await tx.userOnboarding.create({ data: { userId: actor.userId, step: first.id } }); role: "bot",
return postInTransaction(tx, target, [ blocks,
first.card(await loadIntakeContext(tx, actor.userId, new Date())), botId: target.botId,
]); });
const event = await appendEventInTransaction(tx, {
spaceId: target.spaceId,
threadId: target.threadId,
botId: target.botId,
type: "thread.message.created",
payload: { messageId: message.id, role: "bot", blocks },
});
return { message, event };
}); });
if (committed) await deps.events.notify(target.threadId, committed.seq); if (!committed) return;
await deps.events.notify(target.threadId, committed.event.seq);
} }
/** Dismisses the pending card. An onboarding question counts as skipped, so the next follows. */
export async function dismissFocus( export async function dismissFocus(
deps: OnboardingDeps, deps: OnboardingDeps,
actor: Actor, actor: Actor,
@ -201,128 +205,81 @@ export async function dismissFocus(
): Promise<void> { ): Promise<void> {
const { bot, thread } = await requireBotThread(deps, actor, botId); const { bot, thread } = await requireBotThread(deps, actor, botId);
const target = { spaceId: actor.spaceId, botId: bot.id, threadId: thread.id }; const target = { spaceId: actor.spaceId, botId: bot.id, threadId: thread.id };
const pending = await findPendingCard(deps.prisma, thread.id); const claimed = await deps.prisma.$transaction(async (tx: Prisma.TransactionClient) => {
const skip = pending?.card.step ? secondaryOption(pending.card) : undefined; await tx.$executeRaw`SELECT id FROM threads WHERE id = ${thread.id} FOR UPDATE`;
if (pending?.card.step && skip) { const recent = await tx.message.findMany({
await chooseFocus(deps, actor, botId, { step: pending.card.step, optionId: skip.id }); where: { threadId: thread.id },
return; orderBy: { createdAt: "asc" },
} });
const event = await deps.prisma.$transaction(async (tx: Prisma.TransactionClient) => { const pending = recent.find((message) =>
await lockThread(tx, thread.id); messageHasPendingChoice(message.blocks as MessageBlock[]),
const current = await findPendingCard(tx, thread.id);
if (!current || current.card.step) return null;
const blocks = current.blocks.map((block) =>
block === current.card ? { ...current.card, answerId: FOCUS_DISMISSED_ANSWER_ID } : block,
); );
return updateBlocksInTransaction(tx, target, current.messageId, blocks); if (!pending) return null;
const blocks = (pending.blocks as MessageBlock[]).map((block) =>
block.kind === "choice" && !block.answerId
? { ...block, answerId: FOCUS_DISMISSED_ANSWER_ID }
: block,
);
await tx.message.update({ where: { id: pending.id }, data: { blocks } });
const event = await appendEventInTransaction(tx, {
spaceId: target.spaceId,
threadId: target.threadId,
botId: target.botId,
type: "thread.message.updated",
payload: { messageId: pending.id, role: "bot", blocks },
});
return { messageId: pending.id, blocks, event };
}); });
if (event) await deps.events.notify(target.threadId, event.seq); if (!claimed) return;
await deps.events.notify(target.threadId, claimed.event.seq);
} }
export interface ChooseInput {
step?: OnboardingStep;
optionId: string;
text?: string;
canEnablePush?: boolean;
}
/**
* Answers the pending card. The answer is checked against that card, a stale answer (another
* card is pending by now) is ignored, and everything that follows is committed together with
* it: the focus card's app suggestions, then the next onboarding question or the closing line.
*/
export async function chooseFocus( export async function chooseFocus(
deps: OnboardingDeps, deps: OnboardingDeps,
actor: Actor, actor: Actor,
botId: string, botId: string,
input: ChooseInput, optionId: string,
): Promise<void> { ): Promise<void> {
const option = FOCUS_OPTIONS.find((entry) => entry.id === optionId);
if (!option) throw new IsolationError();
const { bot, thread } = await requireBotThread(deps, actor, botId); const { bot, thread } = await requireBotThread(deps, actor, botId);
const target = { spaceId: actor.spaceId, botId: bot.id, threadId: thread.id }; const target = { spaceId: actor.spaceId, botId: bot.id, threadId: thread.id };
const pending = await findPendingCard(deps.prisma, thread.id);
if (!pending || pending.card.step !== input.step) return;
const answer = readIntakeAnswer(pending.card, input.optionId, input.text);
if (!answer) throw new ORPCError("BAD_REQUEST", { message: "This card has no such answer" });
const focus =
!pending.card.step || pending.card.step === "focus"
? FOCUS_OPTIONS.find((option) => option.id === answer.option?.id)
: undefined;
// Fetched before the transaction: the connector catalog can take seconds.
const followUp = focus ? await focusFollowUp(deps, actor, bot.id, focus) : [];
const event = await deps.prisma.$transaction(async (tx: Prisma.TransactionClient) => { const claimed = await deps.prisma.$transaction(async (tx: Prisma.TransactionClient) => {
await lockThread(tx, thread.id); await tx.$executeRaw`SELECT id FROM threads WHERE id = ${thread.id} FOR UPDATE`;
const current = await findPendingCard(tx, thread.id); const recent = await tx.message.findMany({
if (current?.messageId !== pending.messageId || current.card.step !== input.step) return null; where: { threadId: thread.id },
const onboarding = current.card.step orderBy: { createdAt: "asc" },
? await tx.userOnboarding.findUnique({ where: { userId: actor.userId } }) });
: null; const pending = recent.find((message) =>
if (current.card.step) { messageHasPendingChoice(message.blocks as MessageBlock[]),
if (onboarding?.step !== current.card.step) return null; );
await intakeStep(current.card.step).save( if (!pending) return null;
{ prisma: deps.prisma, jobs: deps.jobs, tx, userId: actor.userId }, const blocks = (pending.blocks as MessageBlock[]).map((block) =>
answer, block.kind === "choice" ? { ...block, answerId: option.id } : block,
); );
} await tx.message.update({ where: { id: pending.id }, data: { blocks } });
const answered = answeredCard(current.card, answer); const event = await appendEventInTransaction(tx, {
const blocks = current.blocks.map((block) => (block === current.card ? answered : block)); spaceId: target.spaceId,
let last = await updateBlocksInTransaction(tx, target, current.messageId, blocks); threadId: target.threadId,
for (const message of followUp) last = await postInTransaction(tx, target, message); botId: target.botId,
if (current.card.step) { type: "thread.message.updated",
last = await askNext(deps, tx, target, actor.userId, current.card.step, input.canEnablePush); payload: { messageId: pending.id, role: "bot", blocks },
} });
return last; return { messageId: pending.id, blocks, event };
}); });
if (event) await deps.events.notify(target.threadId, event.seq); if (!claimed) return;
} await deps.events.notify(target.threadId, claimed.event.seq);
function answeredCard(card: ChoiceBlock, answer: IntakeAnswer): ChoiceBlock { // Keep the name and title the user chose when creating the bot; the focus
return answer.option // step only suggests apps, it must not rename the bot.
? { ...card, answerId: answer.option.id } await post(deps, target, [
: { ...card, answerId: CHOICE_TEXT_ANSWER_ID, answerText: answer.text }; {
} kind: "text",
text: `Got it. ${capitalize(option.summary)}. I’ll see what’s already connected so I don’t make you set something up twice.`,
},
]);
async function loadIntakeContext(
tx: Prisma.TransactionClient,
userId: string,
now: Date,
): Promise<IntakeContext> {
const profile = await tx.userProfile.findUnique({
where: { userId },
select: { locale: true, timeZone: true },
});
return intakeContext(profile, now);
}
/** Posts the next onboarding question, or the closing line once every step is answered. */
async function askNext(
deps: OnboardingDeps,
tx: Prisma.TransactionClient,
target: Target,
userId: string,
answeredStep: OnboardingStep,
canEnablePush = false,
): Promise<{ seq: number }> {
const now = new Date();
const context = await loadIntakeContext(tx, userId, now);
const next = nextIntakeStep(answeredStep, { canEnablePush });
if (next) {
await tx.userOnboarding.update({ where: { userId }, data: { step: next.id } });
return postInTransaction(tx, target, [next.card(context)]);
}
// The rhythm step saved its answer outside this transaction, so the routines are readable.
const rhythm = await getCoachRhythm(deps.prisma, userId);
await tx.userOnboarding.update({ where: { userId }, data: { step: null, completedAt: now } });
return postInTransaction(tx, target, [closingNote(rhythm, context)]);
}
/** The apps worth connecting for a focus, as cards; they need no words around them. */
async function focusFollowUp(
deps: OnboardingDeps,
actor: Actor,
botId: string,
option: FocusOption,
): Promise<MessageBlock[][]> {
const providers = deps.connectors.managedProviders(); const providers = deps.connectors.managedProviders();
const catalog = ( const catalog = (
await Promise.all( await Promise.all(
@ -333,14 +290,14 @@ async function focusFollowUp(
traceId: "onboarding.choose", traceId: "onboarding.choose",
spaceId: actor.spaceId, spaceId: actor.spaceId,
userId: actor.userId, userId: actor.userId,
botId, botId: bot.id,
signal: AbortSignal.timeout(15_000), signal: AbortSignal.timeout(15_000),
}) })
.catch(() => []), .catch(() => []),
), ),
) )
).flat(); ).flat();
const cards = option.apps.flatMap((slug) => { const cards: MessageBlock[] = option.apps.flatMap((slug) => {
const entry = catalog.find( const entry = catalog.find(
(item) => (item) =>
item.slug.toLowerCase() === slug.toLowerCase() || item.slug.toLowerCase() === slug.toLowerCase() ||
@ -359,7 +316,27 @@ async function focusFollowUp(
}, },
]; ];
}); });
return cards.length > 0 ? [cards] : []; if (!cards.length) {
await post(deps, target, [{ kind: "text", text: "What would you like to work on first?" }]);
return;
}
const cardNames = cards
.map((card) => (card.kind === "app_connect" ? card.name : ""))
.filter(Boolean);
const named = `${cardNames.slice(0, -1).join(", ")}${cardNames.length > 1 ? ", and " : ""}${cardNames.at(-1)}`;
await post(deps, target, [
{
kind: "text",
text: `${named} are a good place to start. Connect them here and I’ll use what you already have.`,
},
]);
await post(deps, target, cards);
await post(deps, target, [
{
kind: "text",
text: `Hit those ${cards.length === 1 ? "one" : cards.length === 2 ? "two" : "three"} and I’ll start pulling the picture.`,
},
]);
} }
export async function markAppConnected( export async function markAppConnected(
@ -399,3 +376,7 @@ export async function markAppConnected(
await updateBlocks(deps, target, message.id, next); await updateBlocks(deps, target, message.id, next);
} }
} }
function capitalize(value: string): string {
return value.length > 0 ? (value[0] ?? "").toUpperCase() + value.slice(1) : value;
}

View File

@ -1,43 +0,0 @@
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);
});
});

View File

@ -1,95 +0,0 @@
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,
};
}

View File

@ -1,114 +0,0 @@
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;
}
});
});

View File

@ -1,200 +0,0 @@
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: [],
};
}
}

View File

@ -55,11 +55,7 @@ export function requestBodyLimit(maxSize: number): MiddlewareHandler {
}; };
} }
/** /** Install limits only on framework-parsed JSON surfaces with known payload contracts. */
* Install limits only on framework-parsed JSON surfaces with known payload contracts. Streaming
* artifact uploads (`POST /api/artifacts`) are not buffered: the route enforces the per-kind cap
* from Content-Length and again while writing.
*/
export function mountApiRequestBodyLimits(app: Hono): void { export function mountApiRequestBodyLimits(app: Hono): void {
app.use("/api/auth/*", requestBodyLimit(MAX_AUTH_REQUEST_BYTES)); app.use("/api/auth/*", requestBodyLimit(MAX_AUTH_REQUEST_BYTES));
app.use(`${LOCAL_SETTINGS_RPC}/*`, requestBodyLimit(MAX_RPC_REQUEST_BYTES)); app.use(`${LOCAL_SETTINGS_RPC}/*`, requestBodyLimit(MAX_RPC_REQUEST_BYTES));

View File

@ -224,12 +224,7 @@ describe("model setup gate", () => {
expect(response.status).toBe(200); expect(response.status).toBe(200);
await expect(response.json()).resolves.toEqual({ await expect(response.json()).resolves.toEqual({
json: expect.objectContaining({ json: expect.objectContaining({ needsModel: false }),
needsModel: false,
modelsManaged: true,
defaultProvider: "openrouter",
defaultModel: "test-model",
}),
}); });
}); });
@ -243,13 +238,7 @@ describe("model setup gate", () => {
expect(response.status).toBe(200); expect(response.status).toBe(200);
await expect(response.json()).resolves.toEqual({ await expect(response.json()).resolves.toEqual({
// The connect flow opens on the recommendation, not the keyless deployment default. json: expect.objectContaining({ needsModel: true }),
json: expect.objectContaining({
needsModel: true,
modelsManaged: false,
defaultProvider: "anthropic",
defaultModel: "claude-opus-5-5",
}),
}); });
}); });
@ -638,11 +627,7 @@ describe("computer screen url", () => {
controlRunId: null, controlRunId: null,
}; };
const callScreenUrl = async ( const callScreenUrl = async (connectScreen: () => Promise<unknown>, updateMany = vi.fn()) => {
connectScreen: () => Promise<unknown>,
updateMany = vi.fn(),
webOrigin?: string,
) => {
const prisma = { const prisma = {
bot: { bot: {
findFirst: vi.fn().mockResolvedValue({ findFirst: vi.fn().mockResolvedValue({
@ -675,7 +660,7 @@ describe("computer screen url", () => {
headers: { "content-type": "application/json" }, headers: { "content-type": "application/json" },
body: JSON.stringify({ json: { botId: "bot-1" } }), body: JSON.stringify({ json: { botId: "bot-1" } }),
}), }),
{ prefix: "/rpc", context: { actor, webOrigin } }, { prefix: "/rpc", context: { actor } },
); );
return { response, updateMany }; return { response, updateMany };
}; };
@ -700,16 +685,6 @@ 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 () => { it("returns desktop provider screen URLs without sealing them", async () => {
const { response } = await callScreenUrl(async () => ({ const { response } = await callScreenUrl(async () => ({
url: "desktop://screen/computer-1", url: "desktop://screen/computer-1",
@ -1002,52 +977,3 @@ describe("interrupted computer reservation release", () => {
expect(order).toEqual(["operation", "computer"]); expect(order).toEqual(["operation", "computer"]);
}); });
}); });
describe("mcp oauth redirect", () => {
const begin = async (redirectUri: string, webOrigin?: string) => {
const start = vi.fn().mockResolvedValue({ status: "already_connected" });
const deps = {
prisma: {},
env: { webOrigin: "http://127.0.0.1:5173" },
mcpOAuth: { begin: start },
} as unknown as RouterDeps;
const { response } = await new RPCHandler(createRouter(deps)).handle(
new Request("http://127.0.0.1/rpc/mcp/oauth/begin", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ json: { serverId: "server-1", redirectUri } }),
}),
{
prefix: "/rpc",
context: {
actor: {
userId: "user",
spaceId: "space",
email: "user@rakazo.test",
isDeploymentOwner: true,
},
webOrigin,
},
},
);
return { status: response.status, start };
};
it("returns to the LAN origin the browser opened", async () => {
const { status, start } = await begin(
"http://192.168.0.20:5173/mcp/oauth/callback",
"http://192.168.0.20:5173",
);
expect(status).toBe(200);
expect(start).toHaveBeenCalled();
});
it("rejects a redirect to any other origin", async () => {
const { status, start } = await begin(
"https://attacker.example/mcp/oauth/callback",
"http://192.168.0.20:5173",
);
expect(status).toBe(400);
expect(start).not.toHaveBeenCalled();
});
});

File diff suppressed because it is too large Load Diff

View File

@ -20,16 +20,6 @@ describe("run activity copy", () => {
).toBe("Maya asked: Please check the release workflow."); ).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", () => { it("names a delegated task by its title instead of its wake prompt", () => {
expect( expect(
activityPromptSnippet({ activityPromptSnippet({

View File

@ -26,10 +26,6 @@ export function activityPromptSnippet(
const task = parsed.success ? taskBlockOf(parsed.data) : undefined; const task = parsed.success ? taskBlockOf(parsed.data) : undefined;
return promptSnippet(task?.title ?? input.prompt, max); 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); if (input.trigger !== "bot_message") return promptSnippet(input.prompt, max);
const parsed = MessageBlock.array().safeParse(input.sourceBlocks); const parsed = MessageBlock.array().safeParse(input.sourceBlocks);
const message = parsed.success ? botMessageContext(parsed.data) : undefined; const message = parsed.success ? botMessageContext(parsed.data) : undefined;
@ -49,19 +45,15 @@ export function activityNotificationsEnabled(
return groupId !== null || notifyOnFinish; 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, prisma: PrismaClient,
actor: Actor, actor: Actor,
filter: "active" | "recent", filter: "active" | "recent",
): Promise<RunActivityRow[]> { ): Promise<RunActivityRow[]> {
const rows = await prisma.run.findMany({ const rows = await prisma.run.findMany({
where: { where: {
spaceId: actor.spaceId,
userId: actor.userId, userId: actor.userId,
space: { memberships: { some: { userId: actor.userId } } },
bot: { archivedAt: null }, bot: { archivedAt: null },
...(filter === "active" ...(filter === "active"
? { status: { in: [...ACTIVE_RUN_STATUSES] } } ? { status: { in: [...ACTIVE_RUN_STATUSES] } }
@ -92,7 +84,6 @@ export async function listUserRuns(
groupId: row.thread.groupId, groupId: row.thread.groupId,
groupName: row.thread.group?.name ?? null, groupName: row.thread.group?.name ?? null,
threadId: row.threadId, threadId: row.threadId,
spaceId: row.spaceId,
status: row.status as RunActivityRow["status"], status: row.status as RunActivityRow["status"],
trigger: row.trigger as RunActivityRow["trigger"], trigger: row.trigger as RunActivityRow["trigger"],
notificationsEnabled: activityNotificationsEnabled(row.thread.groupId, row.bot.notifyOnFinish), notificationsEnabled: activityNotificationsEnabled(row.thread.groupId, row.bot.notifyOnFinish),

View File

@ -1,7 +1,6 @@
import type { Actor, MessageBlock, SearchHit } from "@rakazo/contracts"; import type { Actor, MessageBlock, SearchHit } from "@rakazo/contracts";
import { extractLinksFromText, matchesSearchQuery, snippetAroundMatch } from "@rakazo/core"; import { extractLinksFromText, matchesSearchQuery, snippetAroundMatch } from "@rakazo/core";
import type { PrismaClient } from "@rakazo/db"; import { Prisma, type PrismaClient } from "@rakazo/db";
import { Prisma } from "@rakazo/db";
const SEARCH_LIMIT = 25; const SEARCH_LIMIT = 25;
/** Cap name matches so content hits (messages/files/links/routines) keep most of the budget. */ /** Cap name matches so content hits (messages/files/links/routines) keep most of the budget. */
@ -65,65 +64,19 @@ export async function querySpaceSearch(
bucket.push(hit); bucket.push(hit);
} }
const [bots, groups, artifacts, groupArtifacts, routines] = await Promise.all([ const bots = await prisma.bot.findMany({
prisma.bot.findMany({ where: {
where: { spaceId: actor.spaceId,
spaceId: actor.spaceId, userId: actor.userId,
userId: actor.userId, archivedAt: null,
archivedAt: null, OR: [
OR: [ { name: { contains: query, mode: "insensitive" } },
{ name: { contains: query, mode: "insensitive" } }, { title: { contains: query, mode: "insensitive" } },
{ title: { contains: query, mode: "insensitive" } }, { description: { contains: query, mode: "insensitive" } },
{ description: { contains: query, mode: "insensitive" } }, ],
], },
}, take: CONVERSATION_HIT_LIMIT,
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) { for (const bot of bots) {
pushInto( pushInto(
conversationHits, conversationHits,
@ -138,6 +91,14 @@ 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) { for (const group of groups) {
pushInto( pushInto(
conversationHits, conversationHits,
@ -153,72 +114,27 @@ export async function querySpaceSearch(
} }
const contentBudget = Math.max(0, SEARCH_LIMIT - conversationHits.length); 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) { for (const artifact of artifacts) {
if (!artifact.botId || !artifact.bot) continue; if (!artifact.botId || !artifact.bot) continue;
const message = botArtifactMessages.get(artifact.id); const message = botArtifactMessages.get(artifact.id);
@ -239,6 +155,24 @@ 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) { for (const artifact of groupArtifacts) {
if (!artifact.groupId || !artifact.group) continue; if (!artifact.groupId || !artifact.group) continue;
const message = groupArtifactMessages.get(artifact.id); const message = groupArtifactMessages.get(artifact.id);
@ -259,6 +193,19 @@ 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) { for (const routine of routines) {
pushInto( pushInto(
contentHits, contentHits,
@ -274,6 +221,29 @@ 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) { for (const row of messageRows) {
pushMessageHits(row.blocks as MessageBlock[], { pushMessageHits(row.blocks as MessageBlock[], {
botId: row.botId, botId: row.botId,
@ -285,6 +255,28 @@ 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) { for (const row of groupMessageRows) {
pushMessageHits(row.blocks as MessageBlock[], { pushMessageHits(row.blocks as MessageBlock[], {
groupId: row.groupId, groupId: row.groupId,

View File

@ -90,7 +90,7 @@ describe("server update install kind", () => {
if (href.endsWith("/state")) { if (href.endsWith("/state")) {
return new Response( return new Response(
JSON.stringify({ JSON.stringify({
image: "ghcr.io/cynarai/evoo-local/app", image: "ghcr.io/elie222/rakazo/app",
currentTag: "sha-aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", currentTag: "sha-aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
previousTag: "sha-bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", previousTag: "sha-bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
running: false, running: false,
@ -98,7 +98,7 @@ describe("server update install kind", () => {
present: true, present: true,
commit: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", commit: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
branch: "main", branch: "main",
remoteUrl: "https://github.com/cynarAI/evOo-local", remoteUrl: "https://github.com/elie222/rakazo",
dirty: false, dirty: false,
dirtyPaths: [], dirtyPaths: [],
}, },
@ -134,7 +134,7 @@ describe("server update install kind", () => {
fromTag: "sha-aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", fromTag: "sha-aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
toTag: "sha-bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", toTag: "sha-bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
strategy: "pull", strategy: "pull",
repoUrl: "https://github.com/cynarAI/evOo-local", repoUrl: "https://github.com/elie222/rakazo",
branch: "main", branch: "main",
restart: "not-required", restart: "not-required",
restartAdvice: "Recreate failed; prior image restored, env pin not restored.", restartAdvice: "Recreate failed; prior image restored, env pin not restored.",
@ -149,7 +149,7 @@ describe("server update install kind", () => {
if (href.endsWith("/state")) { if (href.endsWith("/state")) {
return new Response( return new Response(
JSON.stringify({ JSON.stringify({
image: "ghcr.io/cynarai/evoo-local/app", image: "ghcr.io/elie222/rakazo/app",
currentTag: "sha-bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", currentTag: "sha-bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
previousTag: "sha-aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", previousTag: "sha-aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
running: false, running: false,
@ -158,7 +158,7 @@ describe("server update install kind", () => {
present: true, present: true,
commit: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", commit: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
branch: "main", branch: "main",
remoteUrl: "https://github.com/cynarAI/evOo-local", remoteUrl: "https://github.com/elie222/rakazo",
dirty: false, dirty: false,
dirtyPaths: [], dirtyPaths: [],
}, },

View File

@ -713,7 +713,7 @@ export class TeamChatBridge {
const blocks = Array.isArray(response?.blocks) ? (response.blocks as MessageBlock[]) : []; const blocks = Array.isArray(response?.blocks) ? (response.blocks as MessageBlock[]) : [];
const content = const content =
run.status === "failed" run.status === "failed"
? `${target.name} could not complete the delegated request. Open evOo for details.` ? `${target.name} could not complete the delegated request. Open Rakazo for details.`
: teamChatResponseText(blocks, target.name); : teamChatResponseText(blocks, target.name);
if (!content) continue; if (!content) continue;
await this.deps.send({ await this.deps.send({
@ -1160,7 +1160,7 @@ export class TeamChatBridge {
await this.sendOnce(message.id, { await this.sendOnce(message.id, {
conversationId: message.externalConversation.conversationId, conversationId: message.externalConversation.conversationId,
replyThreadId: message.replyThreadId, replyThreadId: message.replyThreadId,
content: `${this.target?.name ?? "The agent"} could not complete that request. Open evOo for details.`, content: `${this.target?.name ?? "The agent"} could not complete that request. Open Rakazo for details.`,
idempotencyKey: `external-message:${message.id}:failure`, idempotencyKey: `external-message:${message.id}:failure`,
}); });
} }

View File

@ -88,77 +88,4 @@ describe("model team chat engagement judge", () => {
expect(instructions).toContain("(Europe/Berlin, the user's time zone)"); expect(instructions).toContain("(Europe/Berlin, the user's time zone)");
expect(instructions).toContain("low-cost engagement judge"); 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",
});
});
}); });

View File

@ -1,15 +1,14 @@
import { randomUUID } from "node:crypto"; import { randomUUID } from "node:crypto";
import type { AgentModelOAuthCredential, AgentRuntime } from "@rakazo/adapter-kit"; import type { AgentModelOAuthCredential, AgentRuntime } from "@rakazo/adapter-kit";
import type { EncryptedSecretStore } from "@rakazo/adapters";
import { import {
type EncryptedSecretStore,
formatCurrentTimeInstruction, formatCurrentTimeInstruction,
loadUserTimeZone, loadUserTimeZone,
resolveModelAuth, resolveModelAuth,
serializeModelSecret, serializeModelSecret,
toOAuthCredential, toOAuthCredential,
} from "@rakazo/adapters"; } from "@rakazo/adapters";
import type { PrismaClient } from "@rakazo/db"; import { findDefaultModelCredential, findModelCredential, type PrismaClient } from "@rakazo/db";
import { findDefaultModelCredential, findModelCredential, findSpaceModelTier } from "@rakazo/db";
import { getLogger } from "@rakazo/logging"; import { getLogger } from "@rakazo/logging";
const MAX_RULES_CHARS = 4_000; const MAX_RULES_CHARS = 4_000;
@ -192,39 +191,27 @@ export class ModelTeamChatEngagementJudge implements TeamChatEngagementJudge {
}; };
}; };
} | null> { } | null> {
const settings = await this.deps.prisma.deploymentSettings.findUnique({
where: { id: "default" },
});
const scope = { userId: bot.userId, spaceId: bot.spaceId }; const scope = { userId: bot.userId, spaceId: bot.spaceId };
const explicitJudgeOverride = Boolean(this.deps.providerOverride && this.deps.modelOverride); const defaultCredential = await findDefaultModelCredential(this.deps.prisma, scope);
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 requestedProvider = this.deps.providerOverride ?? bot.modelProvider;
const requestedModel = this.deps.modelOverride ?? bot.modelId; const requestedModel = this.deps.modelOverride ?? bot.modelId;
const overrideCredential = const overrideCredential = requestedProvider
!fastTier && requestedProvider ? await findModelCredential(this.deps.prisma, scope, requestedProvider)
? await findModelCredential(this.deps.prisma, scope, requestedProvider) : null;
: null; const explicitJudgeOverride = Boolean(this.deps.providerOverride && this.deps.modelOverride);
const useOverride = Boolean( const useOverride = Boolean(
requestedProvider && requestedModel && (explicitJudgeOverride || overrideCredential), requestedProvider && requestedModel && (explicitJudgeOverride || overrideCredential),
); );
const credential = const credential = useOverride ? overrideCredential : defaultCredential;
fastTier?.credential ?? (useOverride ? overrideCredential : defaultCredential);
const settings = fastTier
? null
: await this.deps.prisma.deploymentSettings.findUnique({
where: { id: "default" },
});
const provider = const provider =
fastTier?.credential.provider ??
(useOverride ? requestedProvider : null) ?? (useOverride ? requestedProvider : null) ??
credential?.provider ?? credential?.provider ??
settings?.defaultModelProvider ?? settings?.defaultModelProvider ??
this.deps.deploymentProvider; this.deps.deploymentProvider;
const modelId = const modelId =
fastTier?.modelId ??
(useOverride ? requestedModel : null) ?? (useOverride ? requestedModel : null) ??
credential?.defaultModel ?? credential?.defaultModel ??
settings?.defaultModelId ?? settings?.defaultModelId ??

View File

@ -1,275 +0,0 @@
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();
});
});

View File

@ -1,242 +0,0 @@
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;
}

View File

@ -2,17 +2,15 @@ import type { SandboxProvider } from "@rakazo/adapter-kit";
import type { Actor } from "@rakazo/contracts"; import type { Actor } from "@rakazo/contracts";
import type { PrismaClient } from "@rakazo/db"; import type { PrismaClient } from "@rakazo/db";
import { describe, expect, it, vi } from "vitest"; import { describe, expect, it, vi } from "vitest";
import type { ThreadTarget } from "./thread-target.js";
import { import {
cancelSupersededQueuedRuns, cancelSupersededQueuedRuns,
followUpGroupThreadMessage, followUpGroupThreadMessage,
groupDefaultBotId, groupDefaultBotId,
reactToThreadMessage, reactToThreadMessage,
resolveGroupFollowUpBotId, resolveGroupFollowUpBotId,
resumeThreadRuns,
sendQueuedThreadMessagesNow,
sendThreadMessage, sendThreadMessage,
stopThreadRuns, stopThreadRuns,
type ThreadTarget,
threadHead, threadHead,
threadSnapshot, threadSnapshot,
} from "./thread-target.js"; } from "./thread-target.js";
@ -213,13 +211,7 @@ describe("threadSnapshot", () => {
expect.objectContaining({ expect.objectContaining({
where: expect.objectContaining({ where: expect.objectContaining({
type: { type: {
in: [ in: ["thread.progress", "thread.subagent", "agent.tool.called", "agent.tool.completed"],
"thread.message.created",
"thread.progress",
"thread.subagent",
"agent.tool.called",
"agent.tool.completed",
],
}, },
}), }),
}), }),
@ -238,69 +230,6 @@ 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 () => { it("returns the latest failed run so the client can show its error", async () => {
const run = { const run = {
id: "run-failed", id: "run-failed",
@ -1032,13 +961,10 @@ function groupTarget() {
} }
describe("sendThreadMessage", () => { describe("sendThreadMessage", () => {
it("steers a waiting-input run instead of refusing the message", async () => { it("rejects a new bot message while a run is waiting on input", async () => {
const tx = { const tx = {
thread: { thread: {
update: vi update: vi.fn().mockResolvedValue({ nextMessageSeq: 2 }),
.fn()
.mockResolvedValueOnce({ nextMessageSeq: 2 })
.mockResolvedValueOnce({ nextEventSeq: 3 }),
}, },
message: { message: {
create: vi.fn().mockResolvedValue({ create: vi.fn().mockResolvedValue({
@ -1046,7 +972,7 @@ describe("sendThreadMessage", () => {
threadId: "thread-1", threadId: "thread-1",
seq: 1, seq: 1,
role: "user", role: "user",
blocks: [{ kind: "text", text: "more context" }], blocks: [{ kind: "text", text: "hi" }],
botId: null, botId: null,
replyToMessageId: null, replyToMessageId: null,
runId: null, runId: null,
@ -1058,13 +984,9 @@ describe("sendThreadMessage", () => {
findMany: vi findMany: vi
.fn() .fn()
.mockResolvedValue([{ id: "run-waiting", taskId: "task-1", status: "waiting_input" }]), .mockResolvedValue([{ id: "run-waiting", taskId: "task-1", status: "waiting_input" }]),
findUnique: vi.fn().mockResolvedValue({
status: "waiting_input",
startedAt: new Date(),
}),
}, },
steeringMessage: { create: vi.fn() }, steeringMessage: { create: vi.fn() },
event: { create: vi.fn().mockResolvedValue({ seq: 2 }) }, event: { create: vi.fn() },
task: { create: vi.fn() }, task: { create: vi.fn() },
}; };
const prisma = { const prisma = {
@ -1078,37 +1000,26 @@ describe("sendThreadMessage", () => {
threadId: "thread-1", threadId: "thread-1",
bot: { computer: null }, bot: { computer: null },
} as ThreadTarget; } as ThreadTarget;
const enqueue = vi.fn().mockResolvedValue(undefined);
await expect( await expect(
sendThreadMessage( sendThreadMessage(
{ {
prisma, prisma,
events: { notify: vi.fn().mockResolvedValue(undefined) } as never, events: { notify: vi.fn() } as never,
jobs: { enqueue } as never, jobs: { enqueue: vi.fn() } as never,
}, },
actor, actor,
target, target,
{ {
text: "more context", text: "hi",
clientNonce: "nonce-1", clientNonce: "nonce-1",
}, },
), ),
).resolves.toMatchObject({ ).rejects.toMatchObject({
runId: "run-waiting", code: "CONFLICT",
taskId: "task-1", message: "Answer the pending ask first.",
seq: 1,
runIds: ["run-waiting"],
}); });
expect(tx.steeringMessage.create).toHaveBeenCalledWith({ expect(tx.steeringMessage.create).not.toHaveBeenCalled();
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 () => { it("steers a waiting-takeover run instead of refusing the message", async () => {
@ -2120,192 +2031,4 @@ describe("stopThreadRuns", () => {
expect.objectContaining({ runId: "run-b" }), 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);
});
}); });

View File

@ -1,10 +1,5 @@
import { ORPCError } from "@orpc/server"; import { ORPCError } from "@orpc/server";
import { import { type JobPublisher, runContinueJob, type SandboxProvider } from "@rakazo/adapter-kit";
type JobPublisher,
runContinueJob,
runJobKey,
type SandboxProvider,
} from "@rakazo/adapter-kit";
import { cancelComputerRunWork, screenLeaseIdForRun, toComputerRef } from "@rakazo/adapters"; import { cancelComputerRunWork, screenLeaseIdForRun, toComputerRef } from "@rakazo/adapters";
import { import {
type Actor, type Actor,
@ -165,15 +160,9 @@ function quoteAppearsInBlocks(quote: string, blocks: MessageBlock[]): boolean {
} }
const THREAD_MESSAGE_PAGE_SIZE = 100; const THREAD_MESSAGE_PAGE_SIZE = 100;
const RUNS_NEEDING_CONTINUE = new Set(["queued", "waiting_input", "waiting_takeover"]); const RUNS_NEEDING_CONTINUE = new Set(["queued", "waiting_takeover"]);
const STEERABLE_RUN_STATUSES = new Set([ const STEERABLE_RUN_STATUSES = new Set(["queued", "leased", "running", "waiting_takeover"]);
"queued",
"leased",
"running",
"waiting_input",
"waiting_takeover",
]);
type MentionTargetInput = string | { kind: "bot" | "group" | "routine" | "connector"; id: string }; type MentionTargetInput = string | { kind: "bot" | "group" | "routine" | "connector"; id: string };
@ -502,7 +491,6 @@ export async function threadSnapshot(
runId: currentRun.id, runId: currentRun.id,
type: { type: {
in: [ in: [
"thread.message.created",
"thread.progress", "thread.progress",
"thread.subagent", "thread.subagent",
"agent.tool.called", "agent.tool.called",
@ -568,7 +556,6 @@ export async function threadSnapshot(
runId: { in: activeRuns.map((run) => run.id) }, runId: { in: activeRuns.map((run) => run.id) },
type: { type: {
in: [ in: [
"thread.message.created",
"thread.progress", "thread.progress",
"thread.subagent", "thread.subagent",
"agent.tool.called", "agent.tool.called",
@ -644,12 +631,8 @@ function messagesWithLiveEvents(
persisted: ThreadSnapshot["messages"], persisted: ThreadSnapshot["messages"],
liveEvents: Parameters<typeof projectMessages>[0], 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) => { const live = projectMessages(liveEvents).filter((message) => {
if ( if (message.blocks.some((block) => block.kind === "progress" || block.kind === "steps")) {
message.id.startsWith("progress:") &&
message.blocks.some((block) => block.kind === "progress" || block.kind === "steps")
) {
return true; return true;
} }
if (!message.id.startsWith("subagent:")) return false; if (!message.id.startsWith("subagent:")) return false;
@ -1156,50 +1139,63 @@ export async function reactToThreadMessage(
}); });
} }
async function snapshotCancelledRunTeardown(tx: Prisma.TransactionClient, runIds: string[]) { export async function stopThreadRuns(
// 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: { deps: {
prisma: PrismaClient; prisma: PrismaClient;
sandbox: SandboxProvider; sandbox: SandboxProvider;
}, },
actor: Actor, actor: Actor,
snapshot: Awaited<ReturnType<typeof snapshotCancelledRunTeardown>>, target: ThreadTarget,
) { ) {
const { runIds, computers, leases } = snapshot; const { runIds, computers, leases } = await deps.prisma.$transaction(async (tx) => {
if (runIds.length === 0) return; 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 };
});
// Keep the DB lease until after teardown so a replacement run cannot claim the // 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. // screen while we still need the cancelled run's screenLeaseId to release it.
const computerById = new Map(computers.map((computer) => [computer.id, computer])); const computerById = new Map(computers.map((computer) => [computer.id, computer]));
@ -1277,381 +1273,6 @@ async function teardownCancelledRuns(
}); });
} }
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( export async function setThreadUnreadState(
prisma: PrismaClient, prisma: PrismaClient,
actor: Actor, actor: Actor,

View File

@ -1,6 +1,4 @@
import { ORPCError } from "@orpc/server"; import { ORPCError } from "@orpc/server";
import type { CoachRhythmDeps } from "@rakazo/adapters";
import { syncCoachTimeZone } from "@rakazo/adapters";
import type { import type {
Actor, Actor,
ForgetUserFacts, ForgetUserFacts,
@ -31,32 +29,32 @@ export function getProfile(prisma: PrismaClient, actor: Actor): Promise<UserProf
} }
export async function updateProfile( export async function updateProfile(
deps: CoachRhythmDeps, prisma: PrismaClient,
actor: Actor, actor: Actor,
input: UpdateUserProfile, input: UpdateUserProfile,
): Promise<UserProfileState> { ): Promise<UserProfileState> {
try { try {
await updateUserProfile(deps.prisma, actor.userId, input); await updateUserProfile(prisma, actor.userId, input);
} catch (error) { } catch (error) {
throw toRpcError(error); throw toRpcError(error);
} }
if (input.timeZone !== undefined) await syncCoachTimeZone(deps, actor.userId); return getUserProfileState(prisma, actor.userId);
return getUserProfileState(deps.prisma, actor.userId);
} }
/** Unknown ids are ignored, so a caller cannot probe another user's facts. */ /** Unknown ids are ignored, so a caller cannot probe another user's facts. */
export async function forgetProfile( export async function forgetProfile(
deps: CoachRhythmDeps, prisma: PrismaClient,
actor: Actor, actor: Actor,
input: ForgetUserFacts, input: ForgetUserFacts,
): Promise<UserProfileState> { ): Promise<UserProfileState> {
const target = input.factIds await forgetUserFacts(
? { factIds: input.factIds } prisma,
: input.category actor.userId,
? { category: input.category } input.factIds
: { all: true as const }; ? { factIds: input.factIds }
await forgetUserFacts(deps.prisma, actor.userId, target); : input.category
// Forgetting everything clears the time zone too. ? { category: input.category }
if ("all" in target) await syncCoachTimeZone(deps, actor.userId); : { all: true },
return getUserProfileState(deps.prisma, actor.userId); );
return getUserProfileState(prisma, actor.userId);
} }

View File

@ -1,91 +0,0 @@
import type { Server } from "node:http";
import { createServer } from "node:http";
import type { AddressInfo } from "node:net";
import { NoVoiceConfigured } from "@rakazo/adapters";
import type { Actor } from "@rakazo/contracts";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import type { VoiceDeps } from "./voice.js";
import { toVoiceStatus, transcribeVoice } from "./voice.js";
const actor: Actor = {
userId: "user-1",
spaceId: "space-1",
email: "user@rakazo.test",
isDeploymentOwner: false,
};
/** A user without a voice credential, on a deployment whose media settings are `settings`. */
function deps(settings: object | null, ciphertext: string): VoiceDeps {
return {
prisma: {
spaceVoicePreference: { findFirst: async () => null },
integrationProviderConfig: {
findUnique: async () => (settings ? { id: "media", ciphertext } : null),
},
},
secrets: { load: () => JSON.stringify(settings) },
} as unknown as VoiceDeps;
}
describe("dictation through the self-hosted transcription server", () => {
let server: Server;
let baseUrl = "";
const uploads: string[] = [];
beforeAll(async () => {
server = createServer((request, response) => {
const chunks: Buffer[] = [];
request.on("data", (chunk: Buffer) => chunks.push(chunk));
request.on("end", () => {
uploads.push(`${request.url} ${Buffer.concat(chunks).toString("latin1")}`);
response.setHeader("content-type", "application/json");
response.end(JSON.stringify({ text: " Remind me to call Ada. " }));
});
});
await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve));
baseUrl = `http://127.0.0.1:${(server.address() as AddressInfo).port}/v1`;
});
afterAll(async () => {
await new Promise((resolve) => server.close(resolve));
});
it("transcribes when no voice provider can", async () => {
const result = await transcribeVoice(
deps({ comfyui: null, transcription: { baseUrl } }, "media-a"),
actor,
{ audio: new Uint8Array([1, 2, 3]), mimeType: "audio/webm;codecs=opus" },
);
expect(result).toEqual({ text: "Remind me to call Ada." });
expect(uploads[0]).toContain("/v1/audio/transcriptions");
expect(uploads[0]).toContain('filename="audio.webm"');
});
it("keeps asking for a voice key when no server is set up either", async () => {
await expect(
transcribeVoice(deps(null, "none"), actor, {
audio: new Uint8Array([1]),
mimeType: "audio/webm",
}),
).rejects.toBeInstanceOf(NoVoiceConfigured);
});
it("refuses an empty recording before uploading it", async () => {
const before = uploads.length;
await expect(
transcribeVoice(deps({ comfyui: null, transcription: { baseUrl } }, "media-b"), actor, {
audio: new Uint8Array(),
mimeType: "audio/webm",
}),
).rejects.toThrow("That recording is empty or too large.");
expect(uploads).toHaveLength(before);
});
it("reports server dictation in the voice status", () => {
expect(toVoiceStatus(null).transcribe).toBe(false);
expect(toVoiceStatus(null, { mediaTranscription: true })).toMatchObject({
configured: false,
transcribe: true,
});
});
});

View File

@ -13,18 +13,18 @@ import {
describe("toVoiceStatus", () => { describe("toVoiceStatus", () => {
it("treats a saved key without a voice as configured but not ready", () => { it("treats a saved key without a voice as configured but not ready", () => {
expect(toVoiceStatus({ provider: "kokoro", voiceId: "" })).toEqual({ expect(toVoiceStatus({ provider: "elevenlabs", voiceId: "" })).toEqual({
configured: true, configured: true,
ready: false, ready: false,
transcribe: false, transcribe: true,
provider: "kokoro", provider: "elevenlabs",
voiceId: "", voiceId: "",
}); });
}); });
it("is ready once a voice is chosen", () => { it("is ready once a voice is chosen", () => {
expect(toVoiceStatus({ provider: "kokoro", voiceId: "df_eva" }).ready).toBe(true); expect(toVoiceStatus({ provider: "cartesia", voiceId: "katie" }).ready).toBe(true);
expect(toVoiceStatus({ provider: "kokoro", voiceId: "df_eva" }).transcribe).toBe(false); expect(toVoiceStatus({ provider: "cartesia", voiceId: "katie" }).transcribe).toBe(false);
}); });
it("is off when nothing is connected", () => { it("is off when nothing is connected", () => {
@ -39,7 +39,7 @@ describe("toVoiceStatus", () => {
}); });
describe("voice HTTP routes", () => { describe("voice HTTP routes", () => {
it("rejects unauthenticated speak, transcribe, and transcribe-stream", async () => { it("rejects unauthenticated speak and transcribe", async () => {
const app = new Hono(); const app = new Hono();
mountVoiceHttpRoutes(app, {} as VoiceDeps, async () => null); mountVoiceHttpRoutes(app, {} as VoiceDeps, async () => null);
const speak = await app.request("/api/voice/speak", { const speak = await app.request("/api/voice/speak", {
@ -52,20 +52,13 @@ describe("voice HTTP routes", () => {
headers: { "content-type": "application/json" }, headers: { "content-type": "application/json" },
body: JSON.stringify({ audioBase64: "AAAA", mimeType: "audio/webm" }), 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(speak.status).toBe(401);
expect(transcribe.status).toBe(401); expect(transcribe.status).toBe(401);
expect(transcribeStream.status).toBe(401);
}); });
it.each([ it.each([
["/api/voice/speak", MAX_SPEAK_REQUEST_BYTES], ["/api/voice/speak", MAX_SPEAK_REQUEST_BYTES],
["/api/voice/transcribe", MAX_TRANSCRIBE_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", "rejects a declared oversized body on %s without waiting for cancellation",
async (path, max) => { async (path, max) => {
@ -119,22 +112,6 @@ describe("voice HTTP routes", () => {
expect(response.status).toBe(413); expect(response.status).toBe(413);
expect(cancel).toHaveBeenCalledOnce(); 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; const actor = { userId: "user-1", spaceId: "space-1" } as Actor;

View File

@ -1,12 +1,10 @@
import { ORPCError } from "@orpc/server"; import { ORPCError } from "@orpc/server";
import type { AdapterContext } from "@rakazo/adapter-kit"; import type { AdapterContext } from "@rakazo/adapter-kit";
import type { EncryptedSecretStore } from "@rakazo/adapters";
import { import {
createOpenAiCompatibleTranscriptionProvider,
createVoiceProvider, createVoiceProvider,
type EncryptedSecretStore,
isVoiceProviderId, isVoiceProviderId,
listVoiceCatalog, listVoiceCatalog,
loadMediaSettings,
MAX_SPEAK_CHARS, MAX_SPEAK_CHARS,
MAX_TRANSCRIBE_BYTES, MAX_TRANSCRIBE_BYTES,
NoVoiceConfigured, NoVoiceConfigured,
@ -25,7 +23,7 @@ import {
selectSpaceVoicePreference, selectSpaceVoicePreference,
} from "@rakazo/db"; } from "@rakazo/db";
import type { Context, Hono } from "hono"; import type { Context, Hono } from "hono";
import { cancelBody, readBoundedBody } from "./http-body.js"; import { readBoundedBody } from "./http-body.js";
import { withSerializableRetry } from "./serializable-retry.js"; import { withSerializableRetry } from "./serializable-retry.js";
export interface VoiceDeps { export interface VoiceDeps {
@ -53,20 +51,12 @@ export function catalogEntry(provider: string) {
return voiceCatalogEntry(provider); return voiceCatalogEntry(provider);
} }
/** The deployment's self-hosted transcription server, when the owner set one up. */ export function toVoiceStatus(cred: { provider: string; voiceId: string } | null): VoiceStatus {
export async function mediaTranscriptionSettings(deps: VoiceDeps) {
return (await loadMediaSettings(deps).catch(() => null))?.transcription ?? null;
}
export function toVoiceStatus(
cred: { provider: string; voiceId: string } | null,
options: { mediaTranscription?: boolean } = {},
): VoiceStatus {
const entry = cred ? catalogEntry(cred.provider) : undefined; const entry = cred ? catalogEntry(cred.provider) : undefined;
return { return {
configured: Boolean(cred), configured: Boolean(cred),
ready: Boolean(cred?.voiceId), ready: Boolean(cred?.voiceId),
transcribe: Boolean(entry?.transcribe && cred) || Boolean(options.mediaTranscription), transcribe: Boolean(entry?.transcribe && cred),
provider: cred?.provider ?? null, provider: cred?.provider ?? null,
voiceId: cred?.voiceId ?? "", voiceId: cred?.voiceId ?? "",
}; };
@ -96,8 +86,7 @@ export async function loadVoiceCredential(deps: VoiceDeps, actor: Actor, provide
const cred = provider const cred = provider
? await findVoiceCredential(deps.prisma, actor, provider) ? await findVoiceCredential(deps.prisma, actor, provider)
: await findDefaultVoiceCredential(deps.prisma, actor); : await findDefaultVoiceCredential(deps.prisma, actor);
// Credentials of providers that were removed from the catalog no longer speak. if (!cred) return null;
if (!cred || !isVoiceProviderId(cred.provider)) return null;
const secret = await deps.prisma.secret.findFirst({ const secret = await deps.prisma.secret.findFirst({
where: { id: cred.secretId, userId: actor.userId, spaceId: null }, where: { id: cred.secretId, userId: actor.userId, spaceId: null },
}); });
@ -147,11 +136,7 @@ export async function persistVoiceCredential(
let voiceId = input.voiceId?.trim() ?? ""; let voiceId = input.voiceId?.trim() ?? "";
if (!voiceId) { if (!voiceId) {
const voices = await provider.listVoices(input.plaintext, voiceContext(actor, input.signal)); const voices = await provider.listVoices(input.plaintext, voiceContext(actor, input.signal));
const germanVoice = voiceId = voices[0]?.id ?? "";
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 stored = await deps.secrets.put(input.plaintext, voiceContext(actor, input.signal));
const cred = await withSerializableRetry(() => const cred = await withSerializableRetry(() =>
@ -292,22 +277,9 @@ export async function transcribeVoice(
input: { audio: Uint8Array; mimeType: string; signal?: AbortSignal }, input: { audio: Uint8Array; mimeType: string; signal?: AbortSignal },
) { ) {
const loaded = await loadDefaultVoiceCredential(deps, actor); const loaded = await loadDefaultVoiceCredential(deps, actor);
const provider = loaded ? createVoiceProvider(loaded.cred.provider) : null; if (!loaded) throw new NoVoiceConfigured("key");
if (!loaded || !provider?.transcribe) { const provider = createVoiceProvider(loaded.cred.provider);
// Without a voice provider that transcribes, the self-hosted media server can. if (!provider.transcribe) {
const server = await mediaTranscriptionSettings(deps);
if (server) {
if (input.audio.byteLength === 0 || input.audio.byteLength > MAX_TRANSCRIBE_BYTES) {
throw new ORPCError("BAD_REQUEST", { message: "That recording is empty or too large." });
}
const transcript = await createOpenAiCompatibleTranscriptionProvider(server).transcribe({
audio: input.audio,
mimeType: input.mimeType || "audio/webm",
signal: input.signal,
});
return { text: transcript.text };
}
if (!loaded) throw new NoVoiceConfigured("key");
throw new ORPCError("BAD_REQUEST", { throw new ORPCError("BAD_REQUEST", {
message: "This voice provider does not transcribe audio. Use on-device dictation instead.", message: "This voice provider does not transcribe audio. Use on-device dictation instead.",
}); });
@ -380,113 +352,6 @@ export function mountVoiceHttpRoutes(
return voiceHttpError(c, error); 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> { function parseVoiceRequestBody(raw: string): Record<string, unknown> {

View File

@ -1,36 +0,0 @@
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");
});
});

View File

@ -1,35 +0,0 @@
/** 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;
}
}

View File

@ -177,7 +177,7 @@ test("This computer installs and starts the stack, then opens the app", async ()
const appWindowPromise = app.waitForEvent("window"); const appWindowPromise = app.waitForEvent("window");
await setup.getByRole("button", { name: "Continue" }).click(); await setup.getByRole("button", { name: "Continue" }).click();
await expect(setup.locator("#stack-phase")).toHaveText("Downloading evOo…"); await expect(setup.locator("#stack-phase")).toHaveText("Downloading Rakazo…");
await expect(setup.getByRole("button", { name: "Continue" })).toBeDisabled(); 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. // 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"); 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(); const setup = await app.firstWindow();
await setup.getByRole("button", { name: "Continue" }).click(); await setup.getByRole("button", { name: "Continue" }).click();
await expect(setup.locator("#stack-phase")).toHaveText("Downloading evOo…"); await expect(setup.locator("#stack-phase")).toHaveText("Downloading Rakazo…");
// Fake docker sleeps during pull; leave This computer before ready so followStack must not save. // Fake docker sleeps during pull; leave This computer before ready so followStack must not save.
await setup.getByRole("radio", { name: /Existing instance/ }).check(); await setup.getByRole("radio", { name: /Existing instance/ }).check();

View File

@ -83,7 +83,7 @@ test("first run asks whether to use a local or existing instance", async () => {
} }
const setup = await app.firstWindow(); const setup = await app.firstWindow();
await expect(setup.getByRole("heading", { name: "Welcome to evOo" })).toBeVisible(); await expect(setup.getByRole("heading", { name: "Welcome to Rakazo" })).toBeVisible();
await expect(setup.getByText("Choose which server this app should use.")).toBeVisible(); await expect(setup.getByText("Choose which server this app should use.")).toBeVisible();
await expect(setup.getByText("This computer")).toBeVisible(); await expect(setup.getByText("This computer")).toBeVisible();
await expect(setup.getByText("Existing instance")).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.locator("#server-url").fill(serverUrl);
await setup.getByRole("button", { name: "Check connection" }).click(); await setup.getByRole("button", { name: "Check connection" }).click();
await expect(setup.locator("#status")).toHaveText(`evOo answered at ${serverUrl}.`); await expect(setup.locator("#status")).toHaveText(`Rakazo answered at ${serverUrl}.`);
await expect(setup.locator("#status")).toHaveAttribute("data-tone", "ok"); await expect(setup.locator("#status")).toHaveAttribute("data-tone", "ok");
await setup.screenshot({ await setup.screenshot({
@ -437,7 +437,7 @@ test("a malformed address is rejected before anything is written", async () => {
}).toPass(); }).toPass();
}); });
test("a generic web page is not accepted as an evOo server", async () => { test("a generic web page is not accepted as a Rakazo server", async () => {
const plain = createServer((_request, response) => { const plain = createServer((_request, response) => {
response.writeHead(200, { "content-type": "text/html; charset=utf-8" }); response.writeHead(200, { "content-type": "text/html; charset=utf-8" });
response.end("<!doctype html><p>not Rakazo</p>"); response.end("<!doctype html><p>not Rakazo</p>");
@ -454,7 +454,7 @@ test("a generic web page is not accepted as an evOo server", async () => {
await setup.getByRole("button", { name: "Continue" }).click(); await setup.getByRole("button", { name: "Continue" }).click();
await expect(setup.locator("#status")).toHaveText( await expect(setup.locator("#status")).toHaveText(
"That address did not respond like an evOo server.", "That address did not respond like a Rakazo server.",
); );
await expect(async () => { await expect(async () => {
await expect(readFile(path.join(userData, "setup.json"), "utf8")).rejects.toThrow(); 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(); app = await launch();
const setup = await app.firstWindow(); const setup = await app.firstWindow();
await expect(setup.getByRole("heading", { name: "Welcome to evOo" })).toBeVisible(); await expect(setup.getByRole("heading", { name: "Welcome to Rakazo" })).toBeVisible();
await expect(setup.getByRole("radio", { name: /Existing instance/ })).toBeChecked(); await expect(setup.getByRole("radio", { name: /Existing instance/ })).toBeChecked();
await expect(setup.locator("#server-url")).toHaveValue(closedUrl); await expect(setup.locator("#server-url")).toHaveValue(closedUrl);
await expect(setup.locator("#status")).toContainText("Could not reconnect to the saved server."); 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; const setup = await setupPromise;
await expect(setup.getByRole("heading", { name: "Welcome to evOo" })).toBeVisible(); await expect(setup.getByRole("heading", { name: "Welcome to Rakazo" })).toBeVisible();
await expect(setup.locator("#status")).toBeEmpty(); await expect(setup.locator("#status")).toBeEmpty();
// Closing setup without saving restores the connected instance. // Closing setup without saving restores the connected instance.

View File

@ -0,0 +1,2 @@
.build/
.swiftpm/

View File

@ -0,0 +1,18 @@
// swift-tools-version:5.9
import PackageDescription
// PhotosHelperCore holds everything that does not touch PhotoKit (argument parsing,
// cursors, JSON envelopes, the export path guard, hashing) so XCTest runs without
// Photos access. The executable target is the thin PhotoKit layer.
let package = Package(
name: "photos-helper",
platforms: [.macOS(.v13)],
products: [
.executable(name: "photos-helper", targets: ["photos-helper"])
],
targets: [
.target(name: "PhotosHelperCore"),
.executableTarget(name: "photos-helper", dependencies: ["PhotosHelperCore"]),
.testTarget(name: "PhotosHelperCoreTests", dependencies: ["PhotosHelperCore"]),
]
)

View File

@ -0,0 +1,142 @@
import Foundation
public enum ExportVariant: String, Equatable {
/// The untouched original resource, downloaded from iCloud when needed.
case original
/// A smaller rendition of the current version (720p video, 1280 px JPEG).
case proxy
}
public enum Command: Equatable {
/// `prompt: false` only reads the current status and never shows the system dialog.
case authorize(prompt: Bool)
case list(cursor: ListCursor?, limit: Int, since: Date?)
case asset(localIdentifier: String)
case export(localIdentifier: String, destination: String, variant: ExportVariant)
case changes(token: String)
}
public let usageText = """
usage: photos-helper authorize [--no-prompt]
photos-helper list [--cursor <opaque>] [--limit <1-1000>] [--since <iso8601>]
photos-helper asset <localIdentifier>
photos-helper export <localIdentifier> --dest <dir> [--original|--proxy]
photos-helper changes --token <opaque>
"""
/// Parses argv without the program name. Unknown flags, repeated flags and missing
/// values are usage errors so a typo never silently changes behaviour.
public func parseArguments(_ arguments: [String]) throws -> Command {
guard let name = arguments.first else { throw HelperError(.usage, usageText) }
var options = try OptionReader(Array(arguments.dropFirst()))
switch name {
case "authorize":
let noPrompt = options.flag("--no-prompt")
try options.finish(positionals: 0)
return .authorize(prompt: !noPrompt)
case "list":
let cursor = try options.value("--cursor").map(ListCursor.decode)
let limit = try options.value("--limit").map(parseLimit) ?? Limits.defaultPage
let since = try options.value("--since").map { value -> Date in
guard let date = parseTimestamp(value) else {
throw HelperError(.usage, "--since must be an ISO 8601 timestamp.")
}
return date
}
try options.finish(positionals: 0)
return .list(cursor: cursor, limit: limit, since: since)
case "asset":
try options.finish(positionals: 1)
let identifier = try validIdentifier(options.positionals[0])
return .asset(localIdentifier: identifier)
case "export":
guard let destination = options.value("--dest") else {
throw HelperError(.usage, "export needs --dest <dir>.")
}
let original = options.flag("--original")
let proxy = options.flag("--proxy")
if original && proxy { throw HelperError(.usage, "Choose --original or --proxy, not both.") }
try options.finish(positionals: 1)
let identifier = try validIdentifier(options.positionals[0])
return .export(
localIdentifier: identifier,
destination: destination,
variant: proxy ? .proxy : .original)
case "changes":
guard let token = options.value("--token"), !token.isEmpty else {
throw HelperError(.usage, "changes needs --token <opaque>.")
}
try options.finish(positionals: 0)
return .changes(token: token)
default:
throw HelperError(.usage, usageText)
}
}
func parseLimit(_ value: String) throws -> Int {
guard let limit = Int(value), (1...Limits.maxPage).contains(limit) else {
throw HelperError(.usage, "--limit must be between 1 and \(Limits.maxPage).")
}
return limit
}
func validIdentifier(_ value: String) throws -> String {
guard !value.isEmpty, value.utf16.count <= Limits.externalId, !value.hasPrefix("-"),
!value.unicodeScalars.contains(where: { CharacterSet.controlCharacters.contains($0) })
else { throw HelperError(.usage, "Invalid local identifier.") }
return value
}
private struct OptionReader {
private var values: [String: String] = [:]
private var flags: Set<String> = []
private var consumed: Set<String> = []
private(set) var positionals: [String] = []
private static let valueOptions: Set<String> = ["--cursor", "--limit", "--since", "--dest", "--token"]
private static let flagOptions: Set<String> = ["--no-prompt", "--original", "--proxy"]
init(_ arguments: [String]) throws {
var index = 0
while index < arguments.count {
let argument = arguments[index]
if Self.valueOptions.contains(argument) {
guard index + 1 < arguments.count else {
throw HelperError(.usage, "\(argument) needs a value.")
}
guard values[argument] == nil else { throw HelperError(.usage, "\(argument) given twice.") }
values[argument] = arguments[index + 1]
index += 2
} else if Self.flagOptions.contains(argument) {
guard !flags.contains(argument) else { throw HelperError(.usage, "\(argument) given twice.") }
flags.insert(argument)
index += 1
} else if argument.hasPrefix("--") {
throw HelperError(.usage, "Unknown option \(argument).")
} else {
positionals.append(argument)
index += 1
}
}
}
mutating func value(_ name: String) -> String? {
consumed.insert(name)
return values[name]
}
mutating func flag(_ name: String) -> Bool {
consumed.insert(name)
return flags.contains(name)
}
/// Rejects options that belong to another command and surplus positionals.
func finish(positionals expected: Int) throws {
for name in Set(values.keys).union(flags) where !consumed.contains(name) {
throw HelperError(.usage, "\(name) is not valid here.")
}
guard positionals.count == expected else {
throw HelperError(.usage, expected == 0 ? "Unexpected argument." : "Expected \(expected) argument.")
}
}
}

View File

@ -0,0 +1,37 @@
import Foundation
/// A failure the desktop app maps by `code`. Messages never contain library content
/// beyond the identifier the caller passed in.
public struct HelperError: Error, Equatable {
public enum Code: String, Codable, CaseIterable {
case usage
case notAuthorized = "not_authorized"
case notFound = "not_found"
case pathRejected = "path_rejected"
case destinationExists = "destination_exists"
case network
case exportFailed = "export_failed"
case `internal`
}
public let code: Code
public let message: String
public init(_ code: Code, _ message: String) {
self.code = code
self.message = message
}
/// sysexits(3) style codes so a shell user can tell failure classes apart.
public var exitCode: Int32 {
switch code {
case .usage: return 64
case .notFound: return 66
case .pathRejected, .destinationExists: return 73
case .exportFailed: return 74
case .network: return 75
case .notAuthorized: return 77
case .internal: return 70
}
}
}

View File

@ -0,0 +1,249 @@
import Foundation
/// Mirrors `ClientMediaItemSchema` in packages/contracts/src/media.ts. Every key is
/// always present (nullable fields encode as `null`) because the schema has no optional keys.
public struct ClientMediaItem: Encodable, Equatable {
public enum Kind: String, Encodable {
case video
case photo
case livePhoto = "live_photo"
}
public var externalId: String
public var kind: Kind
public var capturedAt: String?
public var durationMs: Int?
public var width: Int?
public var height: Int?
public var fps: Double?
public var gpsLat: Double?
public var gpsLon: Double?
public var deviceModel: String?
public var albumNames: [String]
public var isFavorite: Bool
public var fileName: String?
public init(
externalId: String,
kind: Kind,
capturedAt: Date?,
durationSeconds: Double?,
width: Int?,
height: Int?,
latitude: Double?,
longitude: Double?,
albumNames: [String],
isFavorite: Bool,
fileName: String?
) {
self.externalId = externalId
self.kind = kind
self.capturedAt = capturedAt.map(formatTimestamp)
if let durationSeconds, durationSeconds.isFinite, durationSeconds > 0 {
durationMs = Int((durationSeconds * 1000).rounded())
} else {
durationMs = nil
}
self.width = width.flatMap { $0 > 0 ? $0 : nil }
self.height = height.flatMap { $0 > 0 ? $0 : nil }
// PhotoKit exposes neither frame rate nor camera model without loading the
// file; the server reads both with ffprobe after upload.
fps = nil
deviceModel = nil
let coordinate = roundedCoordinate(latitude: latitude, longitude: longitude)
gpsLat = coordinate?.latitude
gpsLon = coordinate?.longitude
self.albumNames = Array(
albumNames.map { clip($0, maxCharacters: Limits.albumName) }
.filter { !$0.isEmpty }
.prefix(Limits.albumCount))
self.isFavorite = isFavorite
self.fileName = fileName.map { clip($0, maxCharacters: Limits.fileName) }
}
enum CodingKeys: String, CodingKey {
case externalId, kind, capturedAt, durationMs, width, height, fps, gpsLat, gpsLon
case deviceModel, albumNames, isFavorite, fileName
}
public func encode(to encoder: Encoder) throws {
var container = encoder.container(keyedBy: CodingKeys.self)
try container.encode(externalId, forKey: .externalId)
try container.encode(kind, forKey: .kind)
// `encode` on an Optional writes `null`, unlike the synthesized `encodeIfPresent`.
try container.encode(capturedAt, forKey: .capturedAt)
try container.encode(durationMs, forKey: .durationMs)
try container.encode(width, forKey: .width)
try container.encode(height, forKey: .height)
try container.encode(fps, forKey: .fps)
try container.encode(gpsLat, forKey: .gpsLat)
try container.encode(gpsLon, forKey: .gpsLon)
try container.encode(deviceModel, forKey: .deviceModel)
try container.encode(albumNames, forKey: .albumNames)
try container.encode(isFavorite, forKey: .isFavorite)
try container.encode(fileName, forKey: .fileName)
}
}
/// Bounds from the contract schema, applied here so one odd asset cannot make a whole
/// page fail validation in the app.
public enum Limits {
public static let externalId = 200
public static let albumName = 200
public static let albumCount = 50
public static let fileName = 255
public static let defaultPage = 500
public static let maxPage = 1000
/// Above this many changed assets a full relisting is cheaper than one huge document.
public static let maxChanges = 5000
}
/// Clips to `maxCharacters` UTF-16 units (what zod's `.max()` counts) without splitting
/// a character.
public func clip(_ value: String, maxCharacters: Int) -> String {
if value.utf16.count <= maxCharacters { return value }
var result = ""
var units = 0
for character in value {
units += character.utf16.count
if units > maxCharacters { break }
result.append(character)
}
return result
}
/// Two decimals is about 1 km, matching `roundCoordinate` in @rakazo/media. Precise
/// locations never leave the device.
public func roundedCoordinate(latitude: Double?, longitude: Double?) -> (
latitude: Double, longitude: Double
)? {
guard let latitude, let longitude, latitude.isFinite, longitude.isFinite,
(-90...90).contains(latitude), (-180...180).contains(longitude),
!(latitude == 0 && longitude == 0)
else { return nil }
return ((latitude * 100).rounded() / 100, (longitude * 100).rounded() / 100)
}
/// RFC 3339 in UTC with milliseconds, accepted by zod's `datetime({ offset: true })`.
public func formatTimestamp(_ date: Date) -> String {
let formatter = ISO8601DateFormatter()
formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
formatter.timeZone = TimeZone(identifier: "UTC")
return formatter.string(from: date)
}
public func parseTimestamp(_ value: String) -> Date? {
let formatter = ISO8601DateFormatter()
formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
if let date = formatter.date(from: value) { return date }
formatter.formatOptions = [.withInternetDateTime]
return formatter.date(from: value)
}
public enum AuthorizationState: String, Encodable {
case authorized, limited, denied, restricted, notDetermined
}
public struct AuthorizationOutput: Encodable, Equatable {
public let status: AuthorizationState
public init(status: AuthorizationState) { self.status = status }
}
public struct ListOutput: Encodable {
public let items: [ClientMediaItem]
public let nextCursor: String?
/// Library change token captured before enumeration; `changes --token` resumes from it.
public let changeToken: String?
public init(items: [ClientMediaItem], nextCursor: String?, changeToken: String?) {
self.items = items
self.nextCursor = nextCursor
self.changeToken = changeToken
}
enum CodingKeys: String, CodingKey { case items, nextCursor, changeToken }
public func encode(to encoder: Encoder) throws {
var container = encoder.container(keyedBy: CodingKeys.self)
try container.encode(items, forKey: .items)
try container.encode(nextCursor, forKey: .nextCursor)
try container.encode(changeToken, forKey: .changeToken)
}
}
public struct AssetResourceInfo: Encodable, Equatable {
public let type: String
public let fileName: String
public let uti: String
public init(type: String, fileName: String, uti: String) {
self.type = type
self.fileName = fileName
self.uti = uti
}
}
public struct AssetOutput: Encodable {
public let item: ClientMediaItem
public let modifiedAt: String?
public let hidden: Bool
public let resources: [AssetResourceInfo]
public init(item: ClientMediaItem, modifiedAt: Date?, hidden: Bool, resources: [AssetResourceInfo]) {
self.item = item
self.modifiedAt = modifiedAt.map(formatTimestamp)
self.hidden = hidden
self.resources = resources
}
enum CodingKeys: String, CodingKey { case item, modifiedAt, hidden, resources }
public func encode(to encoder: Encoder) throws {
var container = encoder.container(keyedBy: CodingKeys.self)
try container.encode(item, forKey: .item)
try container.encode(modifiedAt, forKey: .modifiedAt)
try container.encode(hidden, forKey: .hidden)
try container.encode(resources, forKey: .resources)
}
}
public struct ChangesOutput: Encodable {
/// Token to store for the next call; null only when a full relisting is required.
public let token: String?
public let changed: [ClientMediaItem]
public let deleted: [String]
public let fullResyncRequired: Bool
public init(token: String?, changed: [ClientMediaItem], deleted: [String], fullResyncRequired: Bool) {
self.token = token
self.changed = changed
self.deleted = deleted
self.fullResyncRequired = fullResyncRequired
}
public static func fullResync() -> ChangesOutput {
ChangesOutput(token: nil, changed: [], deleted: [], fullResyncRequired: true)
}
enum CodingKeys: String, CodingKey { case token, changed, deleted, fullResyncRequired }
public func encode(to encoder: Encoder) throws {
var container = encoder.container(keyedBy: CodingKeys.self)
try container.encode(token, forKey: .token)
try container.encode(changed, forKey: .changed)
try container.encode(deleted, forKey: .deleted)
try container.encode(fullResyncRequired, forKey: .fullResyncRequired)
}
}
public struct ExportOutput: Encodable, Equatable {
public let path: String
public let fileName: String
public let sizeBytes: Int64
public let sha256: String
public init(path: String, fileName: String, sizeBytes: Int64, sha256: String) {
self.path = path
self.fileName = fileName
self.sizeBytes = sizeBytes
self.sha256 = sha256
}
}

View File

@ -0,0 +1,56 @@
import CryptoKit
import Foundation
/// stdout carries exactly one JSON document per successful command; stderr carries
/// JSON lines (`{"progress":…}` while exporting, `{"error":…}` on failure).
public func encodeJSON<T: Encodable>(_ value: T) throws -> String {
let encoder = JSONEncoder()
encoder.outputFormatting = [.sortedKeys, .withoutEscapingSlashes]
encoder.nonConformingFloatEncodingStrategy = .throw
let data = try encoder.encode(value)
guard let text = String(data: data, encoding: .utf8) else {
throw HelperError(.internal, "Output was not UTF-8.")
}
return text
}
private struct ErrorEnvelope: Encodable {
struct Body: Encodable {
let code: String
let message: String
}
let error: Body
}
public func errorLine(_ error: HelperError) -> String {
let envelope = ErrorEnvelope(error: .init(code: error.code.rawValue, message: error.message))
return (try? encodeJSON(envelope))
?? #"{"error":{"code":"internal","message":"Could not encode the error."}}"#
}
private struct ProgressLine: Encodable {
let progress: Double
}
/// Progress clamped to 0…1 and rounded to three decimals so the line stays short.
public func progressLine(_ fraction: Double) -> String {
let value = fraction.isFinite ? min(1, max(0, fraction)) : 0
return (try? encodeJSON(ProgressLine(progress: (value * 1000).rounded() / 1000)))
?? #"{"progress":0}"#
}
/// Streams the file through SHA-256 in 1 MiB reads so large videos never load fully.
public func sha256Hex(of url: URL) throws -> (hex: String, sizeBytes: Int64) {
let handle = try FileHandle(forReadingFrom: url)
defer { try? handle.close() }
var hasher = SHA256()
var size: Int64 = 0
while true {
let chunk = try handle.read(upToCount: 1 << 20) ?? Data()
if chunk.isEmpty { break }
hasher.update(data: chunk)
size += Int64(chunk.count)
}
let hex = hasher.finalize().map { String(format: "%02x", $0) }.joined()
return (hex, size)
}

View File

@ -0,0 +1,131 @@
import Foundation
/// Listing order: undated assets first, then creation date ascending, ties broken by
/// local identifier. PhotoKit can only sort by date, so ties are ordered here.
public struct SortKey: Comparable, Hashable {
/// Milliseconds since 1970; nil for assets without a creation date.
public let creationMillis: Int64?
public let localIdentifier: String
public init(creationMillis: Int64?, localIdentifier: String) {
self.creationMillis = creationMillis
self.localIdentifier = localIdentifier
}
public init(creationDate: Date?, localIdentifier: String) {
self.init(
creationMillis: creationDate.map { Int64(($0.timeIntervalSince1970 * 1000).rounded(.down)) },
localIdentifier: localIdentifier)
}
public static func < (lhs: SortKey, rhs: SortKey) -> Bool {
if lhs.creationMillis != rhs.creationMillis {
switch (lhs.creationMillis, rhs.creationMillis) {
case (nil, _): return true
case (_, nil): return false
case let (left?, right?): return left < right
}
}
return lhs.localIdentifier < rhs.localIdentifier
}
}
/// Opaque to the app: base64url of `{"v":1,"d":<millis|null>,"id":"<localIdentifier>"}`.
/// The cursor names the last item returned, so listing resumes after it even when
/// assets were added or removed in between.
public struct ListCursor: Equatable {
public let key: SortKey
public init(key: SortKey) { self.key = key }
private struct Payload: Codable {
let v: Int
let d: Int64?
let id: String
enum CodingKeys: String, CodingKey { case v, d, id }
func encode(to encoder: Encoder) throws {
var container = encoder.container(keyedBy: CodingKeys.self)
try container.encode(v, forKey: .v)
try container.encode(d, forKey: .d)
try container.encode(id, forKey: .id)
}
}
public func encoded() -> String {
let payload = Payload(v: 1, d: key.creationMillis, id: key.localIdentifier)
let encoder = JSONEncoder()
encoder.outputFormatting = [.sortedKeys]
// Encoding a struct of plain values cannot fail.
let data = (try? encoder.encode(payload)) ?? Data()
return base64URLEncode(data)
}
public static func decode(_ value: String) throws -> ListCursor {
guard value.count <= 2048, let data = base64URLDecode(value),
let payload = try? JSONDecoder().decode(Payload.self, from: data),
payload.v == 1, !payload.id.isEmpty
else { throw HelperError(.usage, "Invalid cursor.") }
return ListCursor(key: SortKey(creationMillis: payload.d, localIdentifier: payload.id))
}
}
public func base64URLEncode(_ data: Data) -> String {
data.base64EncodedString()
.replacingOccurrences(of: "+", with: "-")
.replacingOccurrences(of: "/", with: "_")
.replacingOccurrences(of: "=", with: "")
}
public func base64URLDecode(_ value: String) -> Data? {
guard value.allSatisfy({ $0.isASCII && ($0.isLetter || $0.isNumber || $0 == "-" || $0 == "_") })
else { return nil }
var base64 = value.replacingOccurrences(of: "-", with: "+").replacingOccurrences(of: "_", with: "/")
while base64.count % 4 != 0 { base64.append("=") }
return Data(base64Encoded: base64)
}
public struct Page {
/// Positions in the source sequence, in listing order.
public let indices: [Int]
public let hasMore: Bool
}
/// Selects the next page from a sequence sorted by creation date only (ties in any
/// order). Binary search finds the first candidate date; a whole same-date group is
/// collected before sorting so tie order never depends on PhotoKit.
public func nextPage(count: Int, key: (Int) -> SortKey, after cursor: ListCursor?, limit: Int)
-> Page
{
precondition(limit > 0)
var start = 0
if let cursor, let cursorMillis = cursor.key.creationMillis {
var low = 0
var high = count
while low < high {
let mid = (low + high) / 2
let millis = key(mid).creationMillis
if millis == nil || millis! < cursorMillis { low = mid + 1 } else { high = mid }
}
start = low
}
var candidates: [(index: Int, key: SortKey)] = []
var stoppedEarly = false
var index = start
while index < count {
let current = key(index)
if candidates.count >= limit, let last = candidates.last, current.creationMillis != last.key.creationMillis {
// Dates ascend, so everything from here on sorts after the collected group.
stoppedEarly = true
break
}
if cursor == nil || cursor!.key < current {
candidates.append((index, current))
}
index += 1
}
candidates.sort { $0.key < $1.key }
let hasMore = stoppedEarly || candidates.count > limit
return Page(indices: candidates.prefix(limit).map(\.index), hasMore: hasMore)
}

View File

@ -0,0 +1,72 @@
import Foundation
/// Where an export is written: a final path and a sibling temporary path inside the
/// destination directory.
public struct ExportDestination: Equatable {
public let directory: URL
public let finalURL: URL
public let temporaryURL: URL
public let fileName: String
}
/// Resolves the export target. The directory must be absolute, free of `..`, existing
/// and a directory after resolving symlinks (macOS temp dirs live behind /var). The file
/// name comes from Photos, so it is reduced to one safe component and the joined path is
/// checked to stay a direct child of the resolved directory. Existing files are never
/// overwritten.
public func resolveExportDestination(
directory: String,
fileName rawFileName: String,
temporarySuffix: String = UUID().uuidString,
fileManager: FileManager = .default
) throws -> ExportDestination {
guard directory.hasPrefix("/"), !directory.contains("\0") else {
throw HelperError(.pathRejected, "--dest must be an absolute directory path.")
}
let components = directory.split(separator: "/", omittingEmptySubsequences: true)
guard !components.contains(where: { $0 == ".." || $0 == "." }) else {
throw HelperError(.pathRejected, "--dest must not contain . or .. segments.")
}
let resolved = URL(fileURLWithPath: directory, isDirectory: true).resolvingSymlinksInPath()
.standardizedFileURL
var isDirectory: ObjCBool = false
guard fileManager.fileExists(atPath: resolved.path, isDirectory: &isDirectory), isDirectory.boolValue
else { throw HelperError(.pathRejected, "--dest must be an existing directory.") }
let fileName = sanitizeFileName(rawFileName)
let finalURL = resolved.appendingPathComponent(fileName, isDirectory: false).standardizedFileURL
let temporaryURL = resolved.appendingPathComponent(
".\(fileName).\(temporarySuffix).partial", isDirectory: false
).standardizedFileURL
for candidate in [finalURL, temporaryURL]
where candidate.deletingLastPathComponent().path != resolved.path {
throw HelperError(.pathRejected, "Export path left the destination directory.")
}
// lstat semantics: a dangling symlink at the final path also counts as existing.
if (try? fileManager.attributesOfItem(atPath: finalURL.path)) != nil {
throw HelperError(.destinationExists, "The destination file already exists.")
}
return ExportDestination(
directory: resolved, finalURL: finalURL, temporaryURL: temporaryURL, fileName: fileName)
}
/// One path component with no separators, control characters or leading dots, at most
/// 200 UTF-8 bytes with the extension kept.
public func sanitizeFileName(_ raw: String) -> String {
var cleaned = String(
raw.unicodeScalars.map { scalar -> Character in
if scalar == "/" || scalar == "\\" || scalar == ":" { return "_" }
if CharacterSet.controlCharacters.contains(scalar) { return "_" }
return Character(scalar)
})
while cleaned.hasPrefix(".") || cleaned.hasPrefix(" ") { cleaned.removeFirst() }
cleaned = cleaned.trimmingCharacters(in: .whitespaces)
if cleaned.isEmpty { return "asset" }
let maxBytes = 200
if cleaned.utf8.count <= maxBytes { return cleaned }
let ext = (cleaned as NSString).pathExtension
let suffix = ext.isEmpty || ext.utf8.count > 16 ? "" : ".\(ext)"
var stem = suffix.isEmpty ? cleaned : String(cleaned.dropLast(suffix.count))
while stem.utf8.count + suffix.utf8.count > maxBytes { stem.removeLast() }
return stem + suffix
}

View File

@ -0,0 +1,148 @@
import AVFoundation
import AppKit
import Foundation
import Photos
import PhotosHelperCore
/// Writes one asset into the destination directory: first to a hidden temporary file,
/// then renamed into place after hashing, so a cancelled export never leaves a file
/// that looks complete.
struct Exporter {
func export(
localIdentifier: String,
directory: String,
variant: ExportVariant,
progress: @escaping (Double) -> Void
) async throws -> ExportOutput {
guard
let asset = PHAsset.fetchAssets(withLocalIdentifiers: [localIdentifier], options: nil)
.firstObject
else { throw HelperError(.notFound, "No photo or video with that identifier.") }
guard let resource = originalResource(for: asset, variant: variant) else {
throw HelperError(.notFound, "The asset has no exportable resource.")
}
var fileName = resource.originalFilename
if variant == .proxy {
let stem = (fileName as NSString).deletingPathExtension
fileName = stem + (asset.mediaType == .video ? ".mp4" : ".jpg")
}
let destination = try resolveExportDestination(directory: directory, fileName: fileName)
let fileManager = FileManager.default
do {
switch (variant, asset.mediaType) {
case (.original, _):
try await writeResource(resource, to: destination.temporaryURL, progress: progress)
case (.proxy, .video):
try await writeVideoProxy(asset, to: destination.temporaryURL, progress: progress)
case (.proxy, _):
try await writeImageProxy(asset, to: destination.temporaryURL, progress: progress)
}
let digest = try sha256Hex(of: destination.temporaryURL)
// moveItem refuses an existing destination, so nothing is ever overwritten.
try fileManager.moveItem(at: destination.temporaryURL, to: destination.finalURL)
progress(1)
return ExportOutput(
path: destination.finalURL.path, fileName: destination.fileName,
sizeBytes: digest.sizeBytes, sha256: digest.hex)
} catch {
try? fileManager.removeItem(at: destination.temporaryURL)
if let helperError = error as? HelperError { throw helperError }
throw mapExportError(error)
}
}
/// Original bytes through PHAssetResourceManager; downloads from iCloud when the
/// library keeps only optimized copies on this Mac.
private func writeResource(
_ resource: PHAssetResource, to url: URL, progress: @escaping (Double) -> Void
) async throws {
let options = PHAssetResourceRequestOptions()
options.isNetworkAccessAllowed = true
options.progressHandler = { value in progress(value) }
try await withCheckedThrowingContinuation { (continuation: CheckedContinuation<Void, Error>) in
PHAssetResourceManager.default().writeData(for: resource, toFile: url, options: options) {
error in
if let error { continuation.resume(throwing: error) } else { continuation.resume() }
}
}
}
/// 720p H.264 MP4 of the current version, without source metadata.
private func writeVideoProxy(
_ asset: PHAsset, to url: URL, progress: @escaping (Double) -> Void
) async throws {
let options = PHVideoRequestOptions()
options.isNetworkAccessAllowed = true
options.deliveryMode = .highQualityFormat
options.version = .current
options.progressHandler = { value, _, _, _ in progress(value * 0.5) }
let session: AVAssetExportSession = try await withCheckedThrowingContinuation { continuation in
PHImageManager.default().requestExportSession(
forVideo: asset, options: options, exportPreset: AVAssetExportPreset1280x720
) { session, info in
if let session {
continuation.resume(returning: session)
} else {
continuation.resume(
throwing: (info?[PHImageErrorKey] as? Error)
?? HelperError(.exportFailed, "Could not prepare the video export."))
}
}
}
session.outputURL = url
session.outputFileType = .mp4
session.shouldOptimizeForNetworkUse = true
session.metadata = []
session.metadataItemFilter = .forSharing()
await session.export()
guard session.status == .completed else {
throw session.error ?? HelperError(.exportFailed, "The video export did not finish.")
}
}
/// JPEG of the current version, at most 1280 px on the long edge, without EXIF/GPS.
private func writeImageProxy(
_ asset: PHAsset, to url: URL, progress: @escaping (Double) -> Void
) async throws {
let options = PHImageRequestOptions()
options.isNetworkAccessAllowed = true
options.deliveryMode = .highQualityFormat
options.resizeMode = .exact
options.version = .current
options.isSynchronous = false
options.progressHandler = { value, _, _, _ in progress(value) }
// highQualityFormat calls the handler exactly once.
let image: NSImage = try await withCheckedThrowingContinuation { continuation in
PHImageManager.default().requestImage(
for: asset, targetSize: CGSize(width: 1280, height: 1280), contentMode: .aspectFit,
options: options
) { image, info in
if let image {
continuation.resume(returning: image)
} else {
continuation.resume(
throwing: (info?[PHImageErrorKey] as? Error)
?? HelperError(.exportFailed, "Could not render the photo."))
}
}
}
guard let cgImage = image.cgImage(forProposedRect: nil, context: nil, hints: nil),
let data = NSBitmapImageRep(cgImage: cgImage).representation(
using: .jpeg, properties: [.compressionFactor: 0.85])
else { throw HelperError(.exportFailed, "Could not encode the photo.") }
try data.write(to: url, options: .withoutOverwriting)
}
}
/// iCloud and network failures are retryable; everything else is an export failure.
func mapExportError(_ error: Error) -> HelperError {
if let photosError = error as? PHPhotosError,
photosError.code == .networkAccessRequired || photosError.code == .networkError
{
return HelperError(.network, "The original could not be downloaded from iCloud.")
}
if (error as NSError).domain == NSURLErrorDomain {
return HelperError(.network, "The original could not be downloaded from iCloud.")
}
return HelperError(.exportFailed, "The export failed.")
}

View File

@ -0,0 +1,183 @@
import Foundation
import Photos
import PhotosHelperCore
/// Read-only PhotoKit queries. Nothing here writes to the library.
struct Library {
private let library = PHPhotoLibrary.shared()
/// Images and videos only; audio and unknown media types are not part of Media Studio.
/// PhotoKit sorts by date only; the store places missing dates first, which is what
/// `SortKey` assumes, and `nextPage` orders ties itself.
private func fetchOptions(since: Date?) -> PHFetchOptions {
let options = PHFetchOptions()
options.includeHiddenAssets = false
var predicates = [
NSPredicate(
format: "mediaType == %ld || mediaType == %ld",
PHAssetMediaType.image.rawValue, PHAssetMediaType.video.rawValue)
]
if let since {
predicates.append(NSPredicate(format: "modificationDate >= %@", since as NSDate))
}
options.predicate = NSCompoundPredicate(andPredicateWithSubpredicates: predicates)
options.sortDescriptors = [NSSortDescriptor(key: "creationDate", ascending: true)]
return options
}
func list(after cursor: ListCursor?, limit: Int, since: Date?) throws -> ListOutput {
// Captured before enumeration so changes made while listing show up next time.
let changeToken = try encodeToken(library.currentChangeToken)
let result = PHAsset.fetchAssets(with: fetchOptions(since: since))
let page = nextPage(
count: result.count,
key: { index in
let asset = result.object(at: index)
return SortKey(creationDate: asset.creationDate, localIdentifier: asset.localIdentifier)
},
after: cursor,
limit: limit)
let assets = page.indices.map { result.object(at: $0) }
let items = assets.compactMap(makeItem(for:))
let nextCursor = page.hasMore
? assets.last.map {
ListCursor(key: SortKey(creationDate: $0.creationDate, localIdentifier: $0.localIdentifier))
.encoded()
}
: nil
return ListOutput(items: items, nextCursor: nextCursor, changeToken: changeToken)
}
func asset(_ localIdentifier: String) throws -> AssetOutput {
guard let asset = PHAsset.fetchAssets(withLocalIdentifiers: [localIdentifier], options: nil).firstObject,
let item = makeItem(for: asset)
else { throw HelperError(.notFound, "No photo or video with that identifier.") }
let resources = PHAssetResource.assetResources(for: asset).map {
AssetResourceInfo(
type: resourceTypeName($0.type), fileName: $0.originalFilename, uti: $0.uniformTypeIdentifier)
}
return AssetOutput(
item: item, modifiedAt: asset.modificationDate, hidden: asset.isHidden, resources: resources)
}
/// Persistent change history (macOS 13). An expired token or missing history asks the
/// caller for a full relisting instead of guessing.
func changes(since encodedToken: String) throws -> ChangesOutput {
guard let token = decodeToken(encodedToken) else { return .fullResync() }
let current = try encodeToken(library.currentChangeToken)
var changed = Set<String>()
var deleted = Set<String>()
do {
let result = try library.fetchPersistentChanges(since: token)
for change in result {
let details = try change.changeDetails(for: PHObjectType.asset)
changed.formUnion(details.insertedLocalIdentifiers)
changed.formUnion(details.updatedLocalIdentifiers)
deleted.formUnion(details.deletedLocalIdentifiers)
if changed.count + deleted.count > Limits.maxChanges { break }
}
} catch let error as PHPhotosError
where error.code == .persistentChangeTokenExpired
|| error.code == .persistentChangeDetailsUnavailable
{
return .fullResync()
} catch {
throw HelperError(.internal, "Could not read the Photos change history.")
}
if changed.count + deleted.count > Limits.maxChanges { return .fullResync() }
changed.subtract(deleted)
var items: [ClientMediaItem] = []
var found = Set<String>()
if !changed.isEmpty {
PHAsset.fetchAssets(withLocalIdentifiers: Array(changed), options: nil).enumerateObjects {
asset, _, _ in
found.insert(asset.localIdentifier)
if let item = makeItem(for: asset) { items.append(item) }
}
}
// Assets that vanished between the history read and the fetch count as deleted.
deleted.formUnion(changed.subtracting(found))
items.sort { $0.externalId < $1.externalId }
return ChangesOutput(
token: current, changed: items, deleted: deleted.sorted(), fullResyncRequired: false)
}
private func encodeToken(_ token: PHPersistentChangeToken) throws -> String {
guard
let data = try? NSKeyedArchiver.archivedData(withRootObject: token, requiringSecureCoding: true)
else { throw HelperError(.internal, "Could not encode the Photos change token.") }
return base64URLEncode(data)
}
private func decodeToken(_ value: String) -> PHPersistentChangeToken? {
guard let data = base64URLDecode(value) else { return nil }
return try? NSKeyedUnarchiver.unarchivedObject(ofClass: PHPersistentChangeToken.self, from: data)
}
}
/// Builds the contract item. Returns nil for media types Media Studio does not handle
/// and for identifiers longer than the contract allows.
func makeItem(for asset: PHAsset) -> ClientMediaItem? {
let kind: ClientMediaItem.Kind
switch asset.mediaType {
case .video: kind = .video
case .image: kind = asset.mediaSubtypes.contains(.photoLive) ? .livePhoto : .photo
default: return nil
}
guard asset.localIdentifier.utf16.count <= Limits.externalId else { return nil }
var albums: [String] = []
PHAssetCollection.fetchAssetCollectionsContaining(asset, with: .album, options: nil)
.enumerateObjects { collection, _, stop in
if let title = collection.localizedTitle { albums.append(title) }
if albums.count >= Limits.albumCount { stop.pointee = true }
}
let coordinate = asset.location?.coordinate
return ClientMediaItem(
externalId: asset.localIdentifier,
kind: kind,
capturedAt: asset.creationDate,
durationSeconds: kind == .video ? asset.duration : nil,
width: asset.pixelWidth,
height: asset.pixelHeight,
latitude: coordinate?.latitude,
longitude: coordinate?.longitude,
albumNames: albums,
isFavorite: asset.isFavorite,
fileName: originalResource(for: asset, variant: .original)?.originalFilename)
}
/// The resource holding the untouched original: the video track for videos, the still
/// for photos and Live Photos (the paired motion clip is not exported).
func originalResource(for asset: PHAsset, variant: ExportVariant) -> PHAssetResource? {
let resources = PHAssetResource.assetResources(for: asset)
let preferred: [PHAssetResourceType]
switch (asset.mediaType, variant) {
case (.video, .original): preferred = [.video, .fullSizeVideo]
case (.video, .proxy): preferred = [.fullSizeVideo, .video]
case (_, .original): preferred = [.photo, .fullSizePhoto]
case (_, .proxy): preferred = [.fullSizePhoto, .photo]
}
for type in preferred {
if let match = resources.first(where: { $0.type == type }) { return match }
}
return nil
}
func resourceTypeName(_ type: PHAssetResourceType) -> String {
switch type {
case .photo: return "photo"
case .video: return "video"
case .audio: return "audio"
case .alternatePhoto: return "alternate_photo"
case .fullSizePhoto: return "full_size_photo"
case .fullSizeVideo: return "full_size_video"
case .adjustmentData: return "adjustment_data"
case .adjustmentBasePhoto: return "adjustment_base_photo"
case .pairedVideo: return "paired_video"
case .fullSizePairedVideo: return "full_size_paired_video"
case .adjustmentBasePairedVideo: return "adjustment_base_paired_video"
case .adjustmentBaseVideo: return "adjustment_base_video"
@unknown default: return "other"
}
}

View File

@ -0,0 +1,103 @@
import Foundation
import Photos
import PhotosHelperCore
/// Entry point. Exactly one JSON document goes to stdout on success; failures write
/// `{"error":{code,message}}` to stderr and exit non-zero (see HelperError.exitCode).
@main
struct PhotosHelper {
static func main() async {
do {
let command = try parseArguments(Array(CommandLine.arguments.dropFirst()))
let output = try await run(command)
FileHandle.standardOutput.write(Data((output + "\n").utf8))
exit(0)
} catch let error as HelperError {
fail(error)
} catch {
fail(HelperError(.internal, "Unexpected failure."))
}
}
static func fail(_ error: HelperError) -> Never {
FileHandle.standardError.write(Data((errorLine(error) + "\n").utf8))
exit(error.exitCode)
}
static func run(_ command: Command) async throws -> String {
switch command {
case .authorize(let prompt):
let status = await authorize(prompt: prompt)
return try encodeJSON(AuthorizationOutput(status: status))
case .list(let cursor, let limit, let since):
try requireAccess()
return try encodeJSON(try Library().list(after: cursor, limit: limit, since: since))
case .asset(let localIdentifier):
try requireAccess()
return try encodeJSON(try Library().asset(localIdentifier))
case .export(let localIdentifier, let destination, let variant):
try requireAccess()
let progress = ProgressReporter()
// Local copies report no progress, so a heartbeat repeats the last value; the app
// treats a silent helper as stalled.
let heartbeat = Task {
while !Task.isCancelled {
try? await Task.sleep(nanoseconds: 15_000_000_000)
if !Task.isCancelled { progress.emit(nil) }
}
}
defer { heartbeat.cancel() }
progress.emit(0)
let result = try await Exporter().export(
localIdentifier: localIdentifier, directory: destination, variant: variant,
progress: { fraction in progress.emit(fraction) })
return try encodeJSON(result)
case .changes(let token):
try requireAccess()
return try encodeJSON(try Library().changes(since: token))
}
}
/// `.readWrite` is the lowest PhotoKit level that can read assets: `.addOnly` cannot
/// enumerate or export anything. The helper never writes to the library.
static func authorize(prompt: Bool) async -> AuthorizationState {
var status = PHPhotoLibrary.authorizationStatus(for: .readWrite)
if prompt && status == .notDetermined {
status = await PHPhotoLibrary.requestAuthorization(for: .readWrite)
}
return state(status)
}
static func state(_ status: PHAuthorizationStatus) -> AuthorizationState {
switch status {
case .authorized: return .authorized
case .limited: return .limited
case .denied: return .denied
case .restricted: return .restricted
case .notDetermined: return .notDetermined
@unknown default: return .denied
}
}
/// Serializes progress lines written from PhotoKit callback queues and the heartbeat.
final class ProgressReporter: @unchecked Sendable {
private let lock = NSLock()
private var last = 0.0
/// nil repeats the last reported value.
func emit(_ fraction: Double?) {
lock.lock()
defer { lock.unlock() }
if let fraction { last = max(last, fraction) }
FileHandle.standardError.write(Data((progressLine(last) + "\n").utf8))
}
}
/// Every command except `authorize` runs only with granted access and never prompts.
static func requireAccess() throws {
switch state(PHPhotoLibrary.authorizationStatus(for: .readWrite)) {
case .authorized, .limited: return
default: throw HelperError(.notAuthorized, "Photos access has not been granted.")
}
}
}

View File

@ -0,0 +1,73 @@
import XCTest
@testable import PhotosHelperCore
final class ArgumentsTests: XCTestCase {
func testAuthorizePromptsUnlessAskedNotTo() throws {
XCTAssertEqual(try parseArguments(["authorize"]), .authorize(prompt: true))
XCTAssertEqual(try parseArguments(["authorize", "--no-prompt"]), .authorize(prompt: false))
}
func testListDefaultsAndOptions() throws {
XCTAssertEqual(try parseArguments(["list"]), .list(cursor: nil, limit: 500, since: nil))
let cursor = ListCursor(key: SortKey(creationMillis: 1_700_000_000_000, localIdentifier: "A/L0/001"))
let parsed = try parseArguments([
"list", "--cursor", cursor.encoded(), "--limit", "25", "--since", "2024-01-02T03:04:05Z",
])
XCTAssertEqual(
parsed,
.list(cursor: cursor, limit: 25, since: Date(timeIntervalSince1970: 1_704_164_645)))
}
func testListRejectsBadValues() {
for arguments in [
["list", "--limit", "0"],
["list", "--limit", "1001"],
["list", "--limit", "ten"],
["list", "--limit"],
["list", "--since", "yesterday"],
["list", "--cursor", "not a cursor"],
["list", "--limit", "5", "--limit", "6"],
["list", "--dest", "/tmp"],
["list", "extra"],
["list", "--unknown"],
] {
XCTAssertThrowsError(try parseArguments(arguments), "\(arguments)") { error in
XCTAssertEqual((error as? HelperError)?.code, .usage)
}
}
}
func testAssetNeedsExactlyOneIdentifier() throws {
XCTAssertEqual(try parseArguments(["asset", "ABC/L0/001"]), .asset(localIdentifier: "ABC/L0/001"))
XCTAssertThrowsError(try parseArguments(["asset"]))
XCTAssertThrowsError(try parseArguments(["asset", "a", "b"]))
XCTAssertThrowsError(try parseArguments(["asset", String(repeating: "x", count: 201)]))
XCTAssertThrowsError(try parseArguments(["asset", "bad\nid"]))
}
func testExportVariants() throws {
XCTAssertEqual(
try parseArguments(["export", "ID", "--dest", "/tmp/out"]),
.export(localIdentifier: "ID", destination: "/tmp/out", variant: .original))
XCTAssertEqual(
try parseArguments(["export", "--dest", "/tmp/out", "ID", "--proxy"]),
.export(localIdentifier: "ID", destination: "/tmp/out", variant: .proxy))
XCTAssertThrowsError(try parseArguments(["export", "ID"]))
XCTAssertThrowsError(try parseArguments(["export", "ID", "--dest", "/tmp", "--original", "--proxy"]))
}
func testChangesNeedsToken() throws {
XCTAssertEqual(try parseArguments(["changes", "--token", "abc"]), .changes(token: "abc"))
XCTAssertThrowsError(try parseArguments(["changes"]))
XCTAssertThrowsError(try parseArguments(["changes", "--token", ""]))
}
func testUnknownCommandIsUsageError() {
XCTAssertThrowsError(try parseArguments([])) { error in
XCTAssertEqual((error as? HelperError)?.code, .usage)
XCTAssertEqual((error as? HelperError)?.exitCode, 64)
}
XCTAssertThrowsError(try parseArguments(["delete", "ID"]))
}
}

View File

@ -0,0 +1,92 @@
import XCTest
@testable import PhotosHelperCore
final class OutputTests: XCTestCase {
private func object(_ json: String) throws -> [String: Any] {
try XCTUnwrap(JSONSerialization.jsonObject(with: Data(json.utf8)) as? [String: Any])
}
func testItemEncodesEveryContractKeyWithExplicitNulls() throws {
let item = ClientMediaItem(
externalId: "ID/L0/001", kind: .photo, capturedAt: nil, durationSeconds: nil, width: 0,
height: nil, latitude: nil, longitude: nil, albumNames: [], isFavorite: false, fileName: nil)
let json = try object(encodeJSON(item))
XCTAssertEqual(
Set(json.keys),
[
"externalId", "kind", "capturedAt", "durationMs", "width", "height", "fps", "gpsLat",
"gpsLon", "deviceModel", "albumNames", "isFavorite", "fileName",
])
for key in ["capturedAt", "durationMs", "width", "height", "fps", "gpsLat", "gpsLon", "deviceModel", "fileName"] {
XCTAssertTrue(json[key] is NSNull, key)
}
}
func testItemNormalizesValues() throws {
let item = ClientMediaItem(
externalId: "V/L0/001", kind: .livePhoto, capturedAt: Date(timeIntervalSince1970: 1_704_164_645.5),
durationSeconds: 12.3456, width: 1920, height: 1080, latitude: 52.520_08, longitude: 13.404_95,
albumNames: ["Trip", "", String(repeating: "a", count: 300)] + Array(repeating: "x", count: 60),
isFavorite: true, fileName: "IMG_0001.HEIC")
let json = try object(encodeJSON(item))
XCTAssertEqual(json["kind"] as? String, "live_photo")
XCTAssertEqual(json["capturedAt"] as? String, "2024-01-02T03:04:05.500Z")
XCTAssertEqual(json["durationMs"] as? Int, 12_346)
XCTAssertEqual(json["gpsLat"] as? Double, 52.52)
XCTAssertEqual(json["gpsLon"] as? Double, 13.4)
let albums = try XCTUnwrap(json["albumNames"] as? [String])
XCTAssertEqual(albums.count, 50)
XCTAssertEqual(albums[1].count, 200)
}
func testInvalidCoordinatesAreDropped() {
XCTAssertNil(roundedCoordinate(latitude: 91, longitude: 0.5))
XCTAssertNil(roundedCoordinate(latitude: .nan, longitude: 1))
XCTAssertNil(roundedCoordinate(latitude: 0, longitude: 0))
XCTAssertNil(roundedCoordinate(latitude: 1, longitude: nil))
}
func testClipCountsUTF16Units() {
XCTAssertEqual(clip("abc", maxCharacters: 5), "abc")
// Each emoji is two UTF-16 units; a half character is never kept.
XCTAssertEqual(clip("😀😀😀", maxCharacters: 5), "😀😀")
}
func testErrorAndProgressLinesAreSingleLineJSON() throws {
let line = errorLine(HelperError(.notFound, "No \"asset\"\nhere."))
XCTAssertFalse(line.contains("\n"))
let error = try XCTUnwrap(try object(line)["error"] as? [String: String])
XCTAssertEqual(error["code"], "not_found")
XCTAssertEqual(error["message"], "No \"asset\"\nhere.")
for (input, expected) in [(0.42424, 0.424), (7, 1), (-1, 0), (.nan, 0)] as [(Double, Double)] {
let progress = progressLine(input)
XCTAssertFalse(progress.contains("\n"))
XCTAssertEqual(try object(progress)["progress"] as? Double ?? -1, expected, accuracy: 1e-9)
}
}
func testListAndChangesKeepNullKeys() throws {
let list = try object(encodeJSON(ListOutput(items: [], nextCursor: nil, changeToken: nil)))
XCTAssertTrue(list["nextCursor"] is NSNull)
XCTAssertTrue(list["changeToken"] is NSNull)
let changes = try object(encodeJSON(ChangesOutput.fullResync()))
XCTAssertTrue(changes["token"] is NSNull)
XCTAssertEqual(changes["fullResyncRequired"] as? Bool, true)
}
func testExitCodesAreDistinctPerFailureClass() {
let codes = HelperError.Code.allCases.map { HelperError($0, "").exitCode }
XCTAssertFalse(codes.contains(0))
XCTAssertEqual(HelperError(.notAuthorized, "").exitCode, 77)
}
func testSHA256StreamsFiles() throws {
let url = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
defer { try? FileManager.default.removeItem(at: url) }
try Data("abc".utf8).write(to: url)
let digest = try sha256Hex(of: url)
XCTAssertEqual(digest.hex, "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad")
XCTAssertEqual(digest.sizeBytes, 3)
}
}

View File

@ -0,0 +1,102 @@
import XCTest
@testable import PhotosHelperCore
final class PagingTests: XCTestCase {
func testCursorRoundTripsAndIsURLSafe() throws {
for key in [
SortKey(creationMillis: 1_700_000_000_123, localIdentifier: "9F2A/L0/001"),
SortKey(creationMillis: nil, localIdentifier: "undated+/=?"),
] {
let encoded = ListCursor(key: key).encoded()
XCTAssertFalse(encoded.contains { "+/=".contains($0) }, encoded)
XCTAssertEqual(try ListCursor.decode(encoded).key, key)
}
}
func testCursorRejectsGarbage() {
for value in ["", "!!!", base64URLEncode(Data("{\"v\":2,\"d\":1,\"id\":\"x\"}".utf8)),
base64URLEncode(Data("{\"v\":1,\"d\":1,\"id\":\"\"}".utf8)), base64URLEncode(Data("[]".utf8))]
{
XCTAssertThrowsError(try ListCursor.decode(value), value)
}
}
func testSortKeyPutsUndatedFirstAndBreaksTiesByIdentifier() {
let keys = [
SortKey(creationMillis: 2, localIdentifier: "a"),
SortKey(creationMillis: 1, localIdentifier: "b"),
SortKey(creationMillis: nil, localIdentifier: "z"),
SortKey(creationMillis: 1, localIdentifier: "a"),
SortKey(creationMillis: nil, localIdentifier: "c"),
]
XCTAssertEqual(
keys.sorted().map { key -> String in
let date = key.creationMillis.map { millis in String(millis) } ?? "-"
return date + key.localIdentifier
},
["-c", "-z", "1a", "1b", "2a"])
}
/// Simulates PhotoKit: sorted by date, ties in arbitrary (here reversed) order.
private func photoKitOrder(_ keys: [SortKey]) -> [SortKey] {
keys.sorted { lhs, rhs in
if lhs.creationMillis == rhs.creationMillis { return lhs.localIdentifier > rhs.localIdentifier }
return lhs < rhs
}
}
private func listAll(_ source: [SortKey], limit: Int) -> [SortKey] {
var cursor: ListCursor?
var listed: [SortKey] = []
var pages = 0
while true {
let page = nextPage(count: source.count, key: { source[$0] }, after: cursor, limit: limit)
XCTAssertLessThanOrEqual(page.indices.count, limit)
listed += page.indices.map { source[$0] }
pages += 1
guard page.hasMore, let last = listed.last else { break }
// The cursor survives a round trip through its encoded form, like the app stores it.
cursor = try? ListCursor.decode(ListCursor(key: last).encoded())
if pages > source.count + 1 { XCTFail("paging did not terminate"); break }
}
return listed
}
func testPagesCoverEverythingOnceInStableOrder() {
var keys: [SortKey] = []
for index in 0..<57 {
// Many equal timestamps (bursts) and a few undated assets.
let millis: Int64? = index % 11 == 0 ? nil : Int64(index / 4)
keys.append(SortKey(creationMillis: millis, localIdentifier: String(format: "id-%03d", index)))
}
let source = photoKitOrder(keys)
for limit in [1, 3, 4, 10, 500] {
XCTAssertEqual(listAll(source, limit: limit), keys.sorted(), "limit \(limit)")
}
}
func testEmptyLibraryHasNoMorePages() {
let page = nextPage(count: 0, key: { _ in fatalError() }, after: nil, limit: 10)
XCTAssertEqual(page.indices, [])
XCTAssertFalse(page.hasMore)
}
func testCursorAfterLastItemReturnsNothing() {
let source = [SortKey(creationMillis: 1, localIdentifier: "a")]
let page = nextPage(
count: 1, key: { source[$0] }, after: ListCursor(key: source[0]), limit: 10)
XCTAssertEqual(page.indices, [])
XCTAssertFalse(page.hasMore)
}
func testDeletedCursorItemStillResumesAfterIt() {
let source = [
SortKey(creationMillis: 1, localIdentifier: "a"),
SortKey(creationMillis: 3, localIdentifier: "c"),
]
let gone = ListCursor(key: SortKey(creationMillis: 2, localIdentifier: "b"))
let page = nextPage(count: 2, key: { source[$0] }, after: gone, limit: 10)
XCTAssertEqual(page.indices, [1])
}
}

View File

@ -0,0 +1,83 @@
import XCTest
@testable import PhotosHelperCore
final class PathGuardTests: XCTestCase {
private var directory: URL!
override func setUpWithError() throws {
directory = FileManager.default.temporaryDirectory.appendingPathComponent(
"photos-helper-\(UUID().uuidString)", isDirectory: true)
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
}
override func tearDownWithError() throws {
try? FileManager.default.removeItem(at: directory)
}
private func resolved() -> URL { directory.resolvingSymlinksInPath().standardizedFileURL }
func testWritesDirectChildrenOfTheResolvedDirectory() throws {
let target = try resolveExportDestination(
directory: directory.path, fileName: "IMG_0001.MOV", temporarySuffix: "t")
XCTAssertEqual(target.finalURL.deletingLastPathComponent().path, resolved().path)
XCTAssertEqual(target.finalURL.lastPathComponent, "IMG_0001.MOV")
XCTAssertEqual(target.temporaryURL.lastPathComponent, ".IMG_0001.MOV.t.partial")
}
func testRejectsRelativeAndTraversingDirectories() {
for path in ["relative/dir", "\(directory.path)/../x", "\(directory.path)/./", "/a/../b", ""] {
XCTAssertThrowsError(
try resolveExportDestination(directory: path, fileName: "a.jpg"), path
) { error in
XCTAssertEqual((error as? HelperError)?.code, .pathRejected)
}
}
}
func testRejectsMissingDirectoryAndFiles() throws {
XCTAssertThrowsError(
try resolveExportDestination(directory: directory.path + "/missing", fileName: "a.jpg"))
let file = directory.appendingPathComponent("file")
try Data().write(to: file)
XCTAssertThrowsError(try resolveExportDestination(directory: file.path, fileName: "a.jpg"))
}
func testHostileFileNamesStayInside() throws {
for name in ["../../etc/passwd", "/etc/passwd", "..", ".hidden", "a/b\\c:d", "\u{0}x", " "] {
let target = try resolveExportDestination(directory: directory.path, fileName: name)
XCTAssertEqual(target.finalURL.deletingLastPathComponent().path, resolved().path, name)
XCTAssertFalse(target.fileName.hasPrefix("."), name)
XCTAssertFalse(target.fileName.contains("/"), name)
}
XCTAssertEqual(sanitizeFileName("../../etc/passwd"), "_.._etc_passwd")
XCTAssertEqual(sanitizeFileName(""), "asset")
}
func testLongNamesKeepTheirExtension() {
let name = sanitizeFileName(String(repeating: "é", count: 300) + ".mov")
XCTAssertLessThanOrEqual(name.utf8.count, 200)
XCTAssertTrue(name.hasSuffix(".mov"))
}
func testNeverOverwritesAnExistingFileOrSymlink() throws {
try Data().write(to: directory.appendingPathComponent("taken.jpg"))
XCTAssertThrowsError(try resolveExportDestination(directory: directory.path, fileName: "taken.jpg")) {
error in XCTAssertEqual((error as? HelperError)?.code, .destinationExists)
}
try FileManager.default.createSymbolicLink(
atPath: directory.appendingPathComponent("link.jpg").path, withDestinationPath: "/nonexistent")
XCTAssertThrowsError(try resolveExportDestination(directory: directory.path, fileName: "link.jpg"))
}
func testSymlinkedDirectoryResolvesToItsTarget() throws {
let link = directory.appendingPathComponent("link", isDirectory: true)
let real = directory.appendingPathComponent("real", isDirectory: true)
try FileManager.default.createDirectory(at: real, withIntermediateDirectories: false)
try FileManager.default.createSymbolicLink(at: link, withDestinationURL: real)
let target = try resolveExportDestination(directory: link.path, fileName: "a.jpg")
XCTAssertEqual(
target.finalURL.deletingLastPathComponent().path,
real.resolvingSymlinksInPath().standardizedFileURL.path)
}
}

View File

@ -0,0 +1,16 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<!-- electron-builder's default hardened-runtime entitlements for Electron. -->
<key>com.apple.security.cs.allow-jit</key>
<true/>
<key>com.apple.security.cs.allow-unsigned-executable-memory</key>
<true/>
<key>com.apple.security.cs.disable-library-validation</key>
<true/>
<!-- Hardened runtime blocks Photos without this, for the app and the bundled photos-helper. -->
<key>com.apple.security.personal-information.photos-library</key>
<true/>
</dict>
</plist>

View File

@ -16,6 +16,8 @@
"release": "pnpm --filter @rakazo/web build && pnpm build && electron-builder --publish never" "release": "pnpm --filter @rakazo/web build && pnpm build && electron-builder --publish never"
}, },
"dependencies": { "dependencies": {
"@orpc/client": "1.15.0",
"@orpc/contract": "1.15.0",
"@rakazo/contracts": "workspace:*", "@rakazo/contracts": "workspace:*",
"electron-updater": "6.8.9" "electron-updater": "6.8.9"
}, },
@ -61,7 +63,18 @@
"dmg", "dmg",
"zip" "zip"
], ],
"notarize": true "notarize": true,
"entitlements": "native/photos-helper/entitlements.mac.plist",
"entitlementsInherit": "native/photos-helper/entitlements.mac.plist",
"extendInfo": {
"NSPhotoLibraryUsageDescription": "Rakazo adds your photos and videos to the media library on your Rakazo server."
},
"extraResources": [
{
"from": "native/photos-helper/.build/apple/Products/Release/photos-helper",
"to": "bin/photos-helper"
}
]
}, },
"win": { "win": {
"icon": "assets/icon.ico", "icon": "assets/icon.ico",
@ -80,8 +93,8 @@
"publish": [ "publish": [
{ {
"provider": "github", "provider": "github",
"owner": "cynarAI", "owner": "elie222",
"repo": "evOo-local" "repo": "rakazo"
} }
] ]
} }

Some files were not shown because too many files have changed in this diff Show More