Compare commits

...

100 Commits

Author SHA1 Message Date
Claude
86465067d6
Merge remote-tracking branch 'origin/main' into claude/phase2-coach
# Conflicts:
#	apps/web/e2e/heute-brief.spec.ts
#	apps/web/src/locales/de/messages.po
#	apps/web/src/locales/en/messages.po
#	apps/web/src/locales/es/messages.po
#	apps/web/src/locales/hi/messages.po
#	apps/web/src/locales/ko/messages.po
#	apps/web/src/locales/pt-BR/messages.po
#	apps/web/src/locales/ru/messages.po
#	apps/web/src/locales/tr/messages.po
#	apps/web/src/locales/zh-CN/messages.po
2026-10-04 08:05:32 +00:00
cynarAI
0c3e3f3d29
feat(web): complete German, English sources and catalogs that cannot go stale (#111)
* fix(web): use English Lingui sources for German UI text

Several web surfaces hard-coded German (the project card, roster and
activity status labels, the sidebar "Heute" label) or used German text as
the Lingui source message (sidebar sections, project timeline, bot picker,
voice entry, group stage, copy and read-aloud buttons). English users saw
German, and because production builds keep only message ids, none of these
strings could be translated per locale.

Convert them to English source messages, keeping the meaning:
- ProjectCreatedCard: Lingui Trans/Plural instead of literals.
- agent-activity: labels go through i18n._ with literal ids so the module
  stays importable from plain unit tests; the extractor still collects them.
- Shell, ProjectSessionTimeline, bot-picker, StageVoiceEntry, OoStage:
  English sources, reusing existing messages where one already fit
  ("just now", "{n}m ago", "Search", "No bots match", "Set up voice",
  "Group conversation", "Transcript").
- The conversation picker matches its create entry against the translated
  label instead of a fixed German string.

The German wording returns through the de catalog in the next commit.
Unit and E2E tests now assert the English labels.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MRtGG9ARfqqhma3t85Ro2u

* feat(web): keep Lingui catalogs complete and in sync

The committed catalogs were last extracted weeks ago: about 330 source
messages were missing from every locale, and production builds keep only
message ids, so those strings rendered as hash ids (sidebar sections,
privacy settings, profile, timeline, ...). German also had 226 empty
entries, and every extraction rewrote the files because references carried
line numbers.

- lingui.config.ts: po references without line numbers, and a stable sort
  that breaks Lingui's ordering ties (explicit ids, equal text) by id, so
  the output no longer depends on file-system order.
- Re-extract all catalogs with --clean (drops 25-44 obsolete entries per
  locale; no existing translation changed).
- Translate every German entry (du form, consistent with the existing
  catalog). Other locales keep empty entries and fall back to English.
- i18n-catalog-sync.test.ts extracts into a copy of the catalogs and fails
  when the committed files differ or a German entry is empty, so stale
  catalogs fail the unit test job (offline, about 5 s).

Adds @lingui/format-po (already installed through @lingui/cli) as a direct
dev dependency to configure the formatter.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MRtGG9ARfqqhma3t85Ro2u

* test(web): find the sidebar Today button by its English label

The shell contrast test added on main looks up the sidebar button by the
old German source text, which this branch replaces with "Today".

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MRtGG9ARfqqhma3t85Ro2u

* chore(web): extract catalogs and translate the in-app browser strings

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MRtGG9ARfqqhma3t85Ro2u

* test: look up the home main conversation in the scheduling journey

Since every project has its own main conversation, the journey that
schedules for a specialist found the user's main conversation with
findFirst over all of them. With the Lab project's conversation also
matching, it picked either one, so the check failed about one run in
three. The lookup now names the home space, where the specialist joined
and the routine is created.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MRtGG9ARfqqhma3t85Ro2u

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-10-04 10:04:21 +02:00
cynarAI
ece3638ce7
fix(ui-web): teach cn() the design system's text and control scales (#115)
* fix(ui-web): teach cn() the design system's text and control scales

cn() used plain tailwind-merge, which only knows Tailwind's default scales.
It read the custom font sizes (text-ui-label, text-ui-caption, ...) as text
colors, so of a size and a color in one class list only the last survived:

- Small, extra-small and large buttons lost their variant's text color. A
  small primary button rendered foreground text on the primary fill (ink on
  ink in the light theme, near-white on white in the dark theme), and
  destructive, secondary and link buttons lost their colors the same way.
- Default-size and icon buttons, tab triggers and the typography helpers
  lost their font size and inherited the parent's instead.

The control and icon spacing scales (h-control-sm, size-icon-md, ...) were
unknown as well, so they never merged with other sizes: an explicit h-auto
or h-9 on a Button, Input or Toggle lost to the component's own height, and
large buttons and toggles drew their icons at the medium size.

cn() now extends tailwind-merge with the text and spacing keys styles.css
adds under @theme. A unit test probes cn() with every @theme key in
styles.css, so a new key that is not registered fails the test.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MRtGG9ARfqqhma3t85Ro2u

* test: look up the home main conversation in the scheduling journey

Since every project has its own main conversation, the journey that
schedules for a specialist found the user's main conversation with
findFirst over all of them. With the Lab project's conversation also
matching, it picked either one, so the check failed about one run in
three. The lookup now names the home space, where the specialist joined
and the routine is created.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MRtGG9ARfqqhma3t85Ro2u

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-10-04 10:04:18 +02:00
cynarAI
59f4a1dfca
perf(api): filter peer-run activity in the transcript page query (#127)
* perf(api): filter peer-run activity in the transcript page query

The transcript loaded a raw page, dropped hidden peer-run rows in memory and
rescanned when nothing visible was left, so a long delegated stretch cost two
queries per hidden page. The page query now joins runs and excludes hidden
peer activity in Postgres, so every page is filled with visible rows. The
kept block kinds live in one list shared by paging, the in-memory filter and
live event forwarding.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YTJRkJrXAS9e3aEFcFYTy8

* test: port the main-conversation journey fix from #126

Journey 5c fails on main since every space got its own main conversation:
the lookup picked the Lab conversation instead of the home one. Same change
as #126 (including its chart-links warm-up), so this PR can go green; it
no-ops once #126 lands.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YTJRkJrXAS9e3aEFcFYTy8

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-10-04 07:41:48 +02:00
cynarAI
735322b016
ops: build the web app in the Evo deploy and add a preview-capable stack unit (#110)
* ops: build the web app in the Evo deploy and add a preview-capable stack unit

The Evo deploy restarted the dev stack without ever building the web app, so a
broken production build went unnoticed and the server could not serve the
build at all. Service workers and web push also need the production web app
behind HTTPS, which the deploy notes did not cover.

deploy.sh now builds the web app after install into a staging directory and
swaps it into apps/web/dist right before the restart, so a failing build
stops the deploy before migrations and the restart and never touches the
build a running vite preview serves. Any failure after the checkout rolls
back the code, reinstalls, regenerates the client, rebuilds the web app when
this deploy had already replaced it, restarts and health-checks; the outcome
is recorded as rolled-back or rollback-failed. The health check now fails
when the API check fails even if the web check passes. Build output goes to a
log that is printed only on failure.

start-stack.sh runs api, worker and sandbox supervisor as pnpm dev does and
serves the web app either from the Vite dev server (default) or from the
build with vite preview on the same port (EVO_WEB_MODE=preview). Preview mode
first checks that a build exists and that RAKAZO_HOST and SCREEN_PROXY_SECRET
resolve the way vite.config.ts resolves them, using the same secret guard.
evoo-local.service is a user unit template for it; the README documents
installing it, switching modes, the catalog caveat for production builds,
and HTTPS through Caddy with EXTRA_TRUSTED_ORIGINS.

A testkit test runs the real scripts against fixture checkouts with fake
pnpm, curl and restart hooks. It replaces the unwired shell regression test,
which exercised a copy of the SHA capture rather than deploy.sh itself.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MRtGG9ARfqqhma3t85Ro2u

* ops: show the build error instead of stack frames when a deploy build fails

A failing web build printed the last 40 lines of its log, which for a syntax
error are mostly bundler stack frames below the message that names the file
and line. Drop the frames before printing the tail.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MRtGG9ARfqqhma3t85Ro2u

* test: look up the home main conversation in the scheduling journey

Since every project has its own main conversation, the journey that
schedules for a specialist found the user's main conversation with
findFirst over all of them. With the Lab project's conversation also
matching, it picked either one, so the check failed about one run in
three. The lookup now names the home space, where the specialist joined
and the routine is created.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MRtGG9ARfqqhma3t85Ro2u

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-10-04 06:35:38 +02:00
Claude
9d6d85cb52
test(web): look for the focus question in the conversation, not the sidebar
The main conversation's sidebar row now shows the pending intake question,
so a page-wide text match finds it twice (strict mode) or never reaches zero.
The specs mean the card in the open conversation, so they look there.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MRtGG9ARfqqhma3t85Ro2u
2026-10-04 04:34:57 +00:00
Claude
31dcdf5293
Merge f/main-preview: localized main conversation preview
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MRtGG9ARfqqhma3t85Ro2u
2026-10-04 04:18:08 +00:00
Claude
a7ae619b28
Merge f/tz-rhythm: move check-ins when the time zone changes
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MRtGG9ARfqqhma3t85Ro2u
2026-10-04 04:18:08 +00:00
Claude
a62e09f36b
fix(web): word the main conversation preview in the user's language during onboarding
While onboarding wrote the main conversation, its sidebar row, project rows and Heute showed the
server preview: nothing for a pending card (a card has no text of its own, so the row fell back
to "Ready") and the English fallback of the closing note, even for a German user.

Groups now carry `previewBlock` next to `preview`: the latest message when it is an onboarding
card or note, validated with the same block schemas the messages use (now named exports). The
web words it with the helpers that render the card and the note, so no copy is duplicated: the
question while a card waits, the answer once given, and the closing note in the user's language
and clock. A dismissed card keeps the plain preview, as the conversation no longer shows it.
The field is left out for every other message, and mobile keeps using the plain `preview`.

The chosen-answer lookup moves from the card into onboarding-copy so the card and the preview
share it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MRtGG9ARfqqhma3t85Ro2u
2026-10-04 04:17:32 +00:00
Claude
68f9dfb34b
fix(coach): move check-ins when the user's time zone changes
The coach check-ins are main-bot routines scheduled in the profile time zone, but a later
zone change (About you, onboarding, the main bot's update_user_profile tool, or forgetting
the whole profile) left them in the old zone until the rhythm was saved again, so they fired
at the wrong local time.

syncCoachTimeZone, next to the rhythm code, moves the main bot's coach routines that are not
in the current profile zone into it, recomputes the next run of armed ones and re-arms their
wakeup. Paused check-ins move but stay paused, routines the user made are never touched, users
without check-ins get none, and a save that leaves the zone as it is changes nothing. The
profile update and forget RPCs and the profile tool call it whenever they write the zone.

Quiet hours, the daily nudge cap and coach dates are evaluated in the profile zone when they
are used, so they need no migration.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MRtGG9ARfqqhma3t85Ro2u
2026-10-04 04:08:05 +00:00
Claude
aea604f7ca
Merge main (light palette) into claude/phase2-coach
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MRtGG9ARfqqhma3t85Ro2u
2026-10-04 03:54:55 +00:00
cynarAI
c4c5ce3c2d
fix(ui): polish the light palette, center the stage bloom (#108)
* fix(ui): make the light theme a real light theme

The web shell hardcoded dark surfaces (panel hex, white/black alphas,
white text), so switching to light left the sidebar, chat, composer and
side panel dark with ink text on top. Shell surfaces now use the shared
tokens: light gets a warm paper frame with white panels, hairline borders
and short soft shadows; dark keeps its look via dark: variants.

- Light palette: visible borders (border was equal to muted), white
  primary-foreground, lighter user bubble, readable warning/success.
- Selected sidebar row lives in one place (roster-row-style).
- Markdown, inline code, code blocks, quotes and scrollbars follow tokens.
- cn() now knows the text-ui-* scale is a font size; tailwind-merge
  treated it as a color and dropped labels' color on resized buttons
  (sign-up submit rendered ink on ink).
- Stage bloom was offset left by a double translateX.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016or6oqCqP8wbNafvhnbLQT

* refactor(ui-web): leave the cn() fix to #115

#115 fixes the same tailwind-merge bug more completely (text, control,
icon and spacing scales, guarded against styles.css) and adds the same
two files, so this branch drops its narrower copy to avoid the overlap.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016or6oqCqP8wbNafvhnbLQT

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-10-04 04:37:24 +02:00
cynarAI
4ba7d843b1
test: fix journey 5c lookup and chart-links warmup (#126)
Look up the home main conversation by spaceId in journey 5c, and warm the chart-links E2E so the first Observable Plot import is not a 20s timeout.
2026-10-04 04:00:43 +02:00
Claude
182a9288c2
test: expect the home main conversation's ask in another space's inbox
Every project now has a main conversation of its own, so an ask waiting in
the home main conversation is not visible from a project and its inbox
lists it, answered in the home space.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MRtGG9ARfqqhma3t85Ro2u
2026-10-04 00:41:43 +00:00
Claude
f071c2257c
test: look up the home main conversation in the scheduling journey
Since every project has its own main conversation, the journey that
schedules for a specialist found the user's main conversation with
findFirst over all of them. With the Lab project's conversation also
matching, it picked either one, so the check failed about one run in
three. The lookup now names the home space, where the specialist joined
and the routine is created.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MRtGG9ARfqqhma3t85Ro2u
2026-10-03 23:46:36 +00:00
Claude
52bbbbfb00
Merge the updated i18n branch (#111, with main) into the Phase 2 integration branch
Catalogs are regenerated with the repo's extractor and keep both sides'
German translations.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MRtGG9ARfqqhma3t85Ro2u
2026-10-03 23:25:08 +00:00
Claude
77e822b9a7
Merge main into the Phase 2 integration branch
Main now gives every project its own main agent. Coaching stays with the
user's personal agent, the main bot of the home space: only it gets the
goal, habit, check-in, brief and nudge tools, the coach summary and the
open-onboarding note, and the tools refuse a project's main bot too. The
onboarding questions start with the personal agent only. A project the
scan creates gets its main agent like any other, and the inbox lists asks
from another space's main conversation.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MRtGG9ARfqqhma3t85Ro2u
2026-10-03 23:24:21 +00:00
Claude
c7d543132d
Merge main into the i18n branch
The project and sidebar specs follow main's project main agents with the
English source labels; the project conversation label is translated.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MRtGG9ARfqqhma3t85Ro2u
2026-10-03 23:16:21 +00:00
cynarAI
2c6b5c3d7b
chore: remove legacy orchestrator, trading and one-off leftovers (#109)
* chore: remove legacy orchestrator, trading and one-off script leftovers

The repository still carried the pre-evOo prototype: a Python orchestrator,
the old HTML "evoo-platform" cockpit (whose test ran in the root unit
suite), an apex-trading package, the hot-reload deploy.sh for it, a
permissions manifest, an architecture note with local file links, agent
handoff notes, a Steve Jobs persona template and about ninety one-off
scripts that probe a developer database with hard-coded local URLs. None
of it is used by the product, CI or the real deploy in ops/evo-deploy.

Delete those files, drop the evoo-platform test glob from the root vitest
config, the Cockpit section from .env.example and the dead biome and
gitignore entries, and update the docs that listed the removed tools.
scripts/start-tmux.sh no longer starts the removed sentinel and resolves
the checkout from its own location instead of a machine-specific path;
scripts/seed-ollama.ts requires DATABASE_URL instead of falling back to a
local URL with a username.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MRtGG9ARfqqhma3t85Ro2u

* fix(web): drop trading and persona prompts from the conversation shell

The web shell still showed leftovers from the trading prototype: composer
chips with German trading and "Steve Jobs" prompts, trading suggestion
chips under the empty conversation line, a German trading hero for the
empty transcript, and a German "data protection" status panel in the
sidebar. They suggested actions the product does not have and added
persistent chrome.

Remove the composer chips and their helper, the empty-state chips and the
hero (the empty conversation keeps its existing one-line start hint), the
sidebar trading indicator and the data trust panel. The new-bot E2E now
asserts the plain empty state; the sidebar spec no longer expects the
trading status, and the placeholder Steve Jobs spec with no assertions is
gone.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MRtGG9ARfqqhma3t85Ro2u

Co-authored-by: cynarAI <cynarAI@users.noreply.github.com>

* refactor: remove the trading bot status integration

With the sidebar trading indicator gone, the only remaining consumer of
the trading bot status was a banner in General settings that reused the
same component. evOo is a personal assistant; a status proxy and poller
for one external trading service, configured through service-specific
TRADING_BOT_URL and VITE_TRADING_BOT_URL variables, does not belong in
the core product.

Remove the settings banner and its prop plumbing, the web poller, the
indicator component, the /api/trading-bot/status proxy, the shared core
contract, the env loading and both variables from .env.example, together
with their tests.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MRtGG9ARfqqhma3t85Ro2u

* chore: remove one-off database scripts and a stale review note

packages/db carried four hand-run scripts outside the package build that
query or seed a developer database with a hard-coded connection string,
one of them for a specific account. docs/VOICE-FIRST-REVIEW.md was a
one-time verification note for a workflow that no longer exists and
named private machines and local paths. Nothing references them.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MRtGG9ARfqqhma3t85Ro2u

* chore: replace machine-specific paths and addresses with placeholders

ORIGIN.md pinned the intended checkout to an absolute path under a
personal home directory, docs/LOCAL-SETUP.md listed folders on one
developer's machine, and two tests used what looks like a real tailnet
address. Use a home-relative path, drop the machine-specific folder list,
and use 100.100.100.100 in the tests; it is still inside 100.64.0.0/10,
so both tests check the same thing as before.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MRtGG9ARfqqhma3t85Ro2u

* test(web): drop the trading status from the shell contrast test

main's contrast test now waits for the sidebar trading status before
measuring; this branch removes that status.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MRtGG9ARfqqhma3t85Ro2u

---------

Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: cynarAI <cynarAI@users.noreply.github.com>
2026-10-04 01:13:52 +02:00
cynarAI
8449dec6c2
feat: give every project its own main agent (#125)
Each project (space) now has a main agent and a main conversation of its
own. The user talks to it, and it delegates to the project's specialists.
Creating a project sets both up and opens the conversation, and deleting
an otherwise empty project removes them with it.


Claude-Session: https://claude.ai/code/session_01R8hSsKTB3W3e61wD6kvzur

Co-authored-by: Claude <noreply@anthropic.com>
2026-10-04 00:38:39 +02:00
Cursor Agent
a51ba87133
fix(web): recover local settings boot when module fetch aborts
Lazy-load the desktop-settings page and its heavy sections so
loadWithRecovery can retry or reload after ERR_NETWORK_CHANGED.
Show the no-desktop alert immediately when the bridge is absent.

Co-authored-by: cynarAI <cynarAI@users.noreply.github.com>
2026-10-03 21:08:49 +00:00
cynarAI
0d1939c775
ci: soft-fail mobile OTA when EXPO_TOKEN is missing (#124)
* ci: soft-fail mobile OTA when EXPO_TOKEN is missing

OTA publish is an infra helper; missing Expo credentials should not
fail the ci workflow or block evo-deploy after green Product CI.

Co-authored-by: cynarAI <cynarAI@users.noreply.github.com>

* test(updater): narrow optional workflow jobs and format the OTA assertion

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015mFCmHk4zPyWpD9yY2C3yJ

---------

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: cynarAI <cynarAI@users.noreply.github.com>
Co-authored-by: Claude <noreply@anthropic.com>
2026-10-03 23:04:17 +02:00
Claude
6e54c873c3
Merge the updated i18n branch (#111, with main) into the Phase 2 integration branch
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MRtGG9ARfqqhma3t85Ro2u
2026-10-03 20:40:37 +00:00
Claude
e1cd9be8a6
Merge the updated legacy cleanup (#109, with main) into the Phase 2 integration branch
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MRtGG9ARfqqhma3t85Ro2u
2026-10-03 20:40:03 +00:00
Claude
82fad0c9ac
Merge main into the i18n branch
Catalogs are extracted again; the artifact pane's row count is translated.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MRtGG9ARfqqhma3t85Ro2u
2026-10-03 20:39:38 +00:00
Claude
3edce64bad
Merge main into the legacy cleanup
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LUTKejm92a8Z7bgSunJChV
2026-10-03 20:34:35 +00:00
cynarAI
d912712668
ci: pass the attestation lookup's jq filter to gh api without -r (#120)
Every image publish on main failed at "Resolve registry attestation
eligibility" with "accepts 1 arg(s), received 2". The step ran
`gh api ... --jq -r '.owner.type'`, but gh api has no -r option: --jq
took "-r" as its filter and the real filter became a second positional
argument. gh api --jq already prints strings without quotes, so the
filter is now passed directly. The images were built and their
manifests pushed; only the attestation decision failed.

The workflow test now rejects `--jq -r`. Run with the repository's
values, the fixed step reports a personal account and a private
repository and skips attestation, as the guard intends.


Claude-Session: https://claude.ai/code/session_01EyQw3AEaHYXegvgvTPAsi1

Co-authored-by: Claude <noreply@anthropic.com>
2026-10-03 22:25:48 +02:00
cynarAI
e28c3acf02
feat: let the pane beside a conversation follow the bot's work (#123)
The live screen opens wide beside the chat while the bot works on it or asks
for a takeover, a file the bot presents as its result opens next to the
conversation, and a question that scrolled away gets a "Needs you" pill back
to it. Shared core rules derive the focus from signals clients already have
(pointer events, takeover requests, a new `present` flag on attach_file,
pending asks), move the pane only on focus edges, never replace a pane the
user opened, and learn per bot whether the screen opens on its own. Pane
widths persist per device; mobile shows a pill that opens the computer.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01C7VSRHHzyt2KwoCkUkZ2Gc
2026-10-03 22:23:46 +02:00
Claude
c7510fa9b3
Merge the updated i18n branch (#111, with main) into the Phase 2 integration branch
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MRtGG9ARfqqhma3t85Ro2u
2026-10-03 18:52:01 +00:00
Claude
adb4dff052
Merge the updated legacy cleanup (#109, with main) into the Phase 2 integration branch
The onboarding scan keeps the consent-first rewrite. Since main's folder
check moved out of wireProjectWorkspace, applying a scan now checks that
each chosen project is still a folder (not only inside the scan folder)
before anything is written; a discovery test covers a project replaced by
a file after the scan.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MRtGG9ARfqqhma3t85Ro2u
2026-10-03 18:51:57 +00:00
Claude
3a72b80122
Merge main into the i18n branch
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MRtGG9ARfqqhma3t85Ro2u
2026-10-03 18:49:55 +00:00
Claude
ee2c5fc3b9
Merge main into the legacy cleanup
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MRtGG9ARfqqhma3t85Ro2u
2026-10-03 18:49:00 +00:00
cynarAI
a15da068d7
fix(api): check a project folder before creating the space (#114)
spaces.create created the space first and only then checked the folder,
so a folder that was missing or not a folder returned 400 but left the
new space behind. The New project dialog shows that error, so fixing the
path and submitting again created a second project with the same name.

The folder is now resolved and checked right after the deployment-owner
check, before the space exists, and the space stores that resolved path.
wireProjectWorkspace takes the resolved folder instead of resolving it
again after the space was created. The onboarding scan, its other
caller, resolves and checks each repository folder the same way before
creating the project, and skips a folder that cannot be linked.

The authorization test has the deployment owner submit a missing folder
and a file (both 400) and then the real folder under the same name, and
checks that exactly one project with that name exists.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012HG4pFTUBDpuCS2Y44TV8x
2026-10-03 20:35:29 +02:00
Claude
7532f17319
Merge the updated i18n branch (#111, with main) into the Phase 2 integration branch
Catalogs are regenerated with the repo's extractor and keep both sides'
German translations.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MRtGG9ARfqqhma3t85Ro2u
2026-10-03 18:27:17 +00:00
Claude
9eb4545793
chore(web): extract catalogs and translate the in-app browser strings
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MRtGG9ARfqqhma3t85Ro2u
2026-10-03 18:24:48 +00:00
Claude
e2cf548d18
Merge main into the i18n branch
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MRtGG9ARfqqhma3t85Ro2u
2026-10-03 18:24:25 +00:00
Claude
09ed6c2136
Merge the updated legacy cleanup (#109, with main) into the Phase 2 integration branch
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MRtGG9ARfqqhma3t85Ro2u
2026-10-03 18:23:22 +00:00
Claude
c641936a2e
Merge main into the legacy cleanup
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MRtGG9ARfqqhma3t85Ro2u
2026-10-03 18:19:09 +00:00
cynarAI
49431def83
feat: show the bot's live cursor and make the in-app browser work (#113)
Bots now announce every click and typing step before it runs, and the
web app draws a cursor in the bot's colour that glides to that point
over the live computer screen, ripples on clicks and shows typing.

- contracts: ComputerPointerPayload on the existing thread.computer event
- executor: computer_act and browser_act announce pointer steps
  (best effort); the page-browser helper reports element screen boxes,
  which are stripped before results reach the model
- web: agentPointerFeed + AgentCursor over the side-panel preview and
  the full-window screen, mapped through the noVNC letterbox
- in-app browser: native WebContentsView on desktop (own session, http(s)
  only, clamped bounds, back/forward, reload, DevTools, load errors) and
  a sandboxed iframe on the web; replaces the dead panel, the fake
  50%/40% cursors and the unimplemented devServer bridge


Claude-Session: https://claude.ai/code/session_01WiGooXtsKrZQmdszepRrNi

Co-authored-by: Claude <noreply@anthropic.com>
2026-10-03 19:08:48 +02:00
Claude
2273e8f82b
test(onboarding): link the foreign folder while its tenant owns the deployment
Since #112 only the deployment owner can link a host folder, so the
discovery test creates the other tenant's linked space while that tenant
holds the deployment, then hands the deployment to the scanning owner.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MRtGG9ARfqqhma3t85Ro2u
2026-10-03 17:08:39 +00:00
Claude
28206f21ca
Merge the updated i18n branch (#111, with main) into the Phase 2 integration branch
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MRtGG9ARfqqhma3t85Ro2u
2026-10-03 17:02:11 +00:00
Claude
ade6c5d116
Merge the updated legacy cleanup (#109, with main) into the Phase 2 integration branch
The consent-first scan replaces the automatic project creation that main
fixed in #116; main's git checkout fix and sidebar classes are kept.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MRtGG9ARfqqhma3t85Ro2u
2026-10-03 17:01:13 +00:00
Claude
f66ca4b7e6
Merge main into the i18n branch
main's shell contrast test finds the sidebar Today button by its English
label here; the sidebar keeps main's token classes with this branch's
English sources.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MRtGG9ARfqqhma3t85Ro2u
2026-10-03 16:56:28 +00:00
Claude
dd5080ea36
test(web): drop the trading status from the shell contrast test
main's contrast test now waits for the sidebar trading status before
measuring; this branch removes that status.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MRtGG9ARfqqhma3t85Ro2u
2026-10-03 16:53:20 +00:00
Claude
bf23bd43c6
Merge main into the legacy cleanup
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MRtGG9ARfqqhma3t85Ro2u
2026-10-03 16:51:55 +00:00
cynarAI
3c3961f242
fix(api): keep host folders to the deployment owner (#112)
spaces.create linked any workspacePath to the new project, and
computer.files and computer.readFile then listed and read files under
that folder for anyone in the space. On a deployment with signups, any
member could link / or the API's own checkout and read whatever the API
process can read, such as its .env file. Linking also ran git in the
chosen folder and stored its remote URL.

A linked folder lives on the host, and the host is the deployment
owner's machine, the rule the onboarding scan already follows:

- spaces.create refuses a folder from anyone but the deployment owner,
  before the space is created or the folder is touched.
- Project files are listed and read only for the deployment owner, so a
  space a member linked before this change stays closed too. Space
  navigation no longer shows a linked folder's path and git remote to
  other members.
- A linked folder is stored as its real path, and listing and reading
  follow symlinks only while they stay inside it. Symlinks that leave
  the folder or lead nowhere are left out of a listing instead of
  failing it.
- The web New project dialog offers the folder field only to the
  deployment owner.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EyQw3AEaHYXegvgvTPAsi1
2026-10-03 18:32:48 +02:00
cynarAI
2b967df569
fix(api): turn repositories found by the onboarding scan into real projects (#116)
The scan's project phase called prisma.space with fields the Space model
does not have (title, path). Both calls threw, a per-repository
try/catch swallowed the errors, and no project was ever created. With
the field names fixed, the space would still have had no organization
or membership, so nobody could have seen it.

The phase now goes through createSpaceForMember and
wireProjectWorkspace, the helpers behind spaces.create: each repository
becomes a project of the deployment owner in their current
organization, linked to its folder with its git remote, branch and
head. It still runs only in the owner's scan with ONBOARDING_SCAN_HOME
set. A repository whose real folder one of the owner's projects already
links is skipped, so scanning again creates nothing; the phase stops at
the per-member space limit; and a second start while a scan runs (a
StrictMode remount, a retry) joins it instead of linking everything
twice. Discovery no longer descends into dot folders, where shell and
editor plugin checkouts would otherwise become projects. The Prisma
client is typed, so a field the schema lacks is a compile error again
instead of a swallowed one.

readGitCheckout loaded child_process with require(), which does not
exist in the ES module runtime the API runs in, so linking any folder
with a .git directory threw "require is not defined", in the scan and
in the New project dialog alike. It now imports spawn statically; the
module is only reachable through its server-only subpath, so browser
bundles still never load it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ebcq9seT9eCoHtqxuWEUzg
2026-10-03 18:22:10 +02:00
Claude
ef15ec61b1
test(web): look for the bot reply in the transcript only
The main conversation's sidebar row now previews the latest reply, so the
unscoped text lookup matched the sidebar too and failed in strict mode.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MRtGG9ARfqqhma3t85Ro2u
2026-10-03 15:57:06 +00:00
cynarAI
f8eabee997
fix(web): keep secondary shell text readable in the light theme (#117)
Secondary shell text was muted text dimmed further with opacity: the
section labels, the projects caption, the sidebar "+" icons, the privacy
panel, the trading subtitle and the Heute brief's Mission Control link.
Muted text measures 4.4:1 on the light sidebar, so these fell to 1.9 to
3.4:1 in the light theme; a few were below 4.5:1 in dark as well.

- Secondary sidebar text and icons share `rk-sidebar-muted`, the sidebar
  foreground at 65% that row times already used (5.0:1 on the light
  sidebar). Row times now use the class too.
- Section labels and space or project group titles use the dimmed
  foreground in light and keep the 80% muted color in dark; group titles
  move up from 60%, which was 3.4:1 in dark.
- The privacy panel heading is plain text with a success-colored check
  instead of emerald text, and its details pass 4.5:1 in both themes.
- Needs-you chips keep the warning tint and border; their label uses the
  foreground color in light.
- The Mission Control link uses the full muted color.
- E2E: the shell test checks every text in the sidebar except the logo,
  and the Heute brief, in both themes, and captures
  `shell-contrast-{light,dark}-heute`.
- AGENTS.md: agents make routine calls themselves, verify the result and
  report it, and ask only before irreversible actions or for product
  decisions without a sensible default.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SSVQGR4WU92LaddTP19GcQ
2026-10-03 17:56:44 +02:00
Claude
7a32100b53
chore(web): extract catalogs and translate the coach strings to German
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MRtGG9ARfqqhma3t85Ro2u
2026-10-03 14:55:01 +00:00
Claude
27f02c05af
style: use top-level type imports in the coach changes
AGENTS.md asks for top-level `import type` instead of inline `type`
specifiers; this applies it to the import statements the coach work added
or changed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MRtGG9ARfqqhma3t85Ro2u
2026-10-03 14:53:03 +00:00
Claude
cf797ce6a5
Merge the updated i18n branch (#111) into the Phase 2 integration branch
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MRtGG9ARfqqhma3t85Ro2u
2026-10-03 14:52:41 +00:00
Claude
06b05711fd
Merge the updated legacy cleanup (#109) into the Phase 2 integration branch
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MRtGG9ARfqqhma3t85Ro2u
2026-10-03 14:52:41 +00:00
Claude
056cabd4ae
Merge web-localized onboarding cards, closing note and check-in names
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MRtGG9ARfqqhma3t85Ro2u
2026-10-03 14:52:40 +00:00
Claude
d3689b88fc
feat(onboarding): show the onboarding cards and check-in names in the user's language
The onboarding cards, the closing line and the coach routine names were English server text
for every user. The server keeps the state and an English fallback; the web now words them
through Lingui, so there is one translation source.

- Choice cards: the web says the question, the option labels and the placeholder from the
  step and option ids, and keeps the server text for anything it does not know (quiet-hour
  ranges are already written in the user's locale). Another bot's focus card is recognized
  by its option ids. Mobile and agent history keep the server text.
- The "Got it. … are a good place to start." line is gone: the app cards speak for
  themselves, and with no cards nothing is said.
- The closing line becomes an intake_note block (note "done", nextCheckInAt, English text).
  The web words it with the first check-in in the user's clock; mobile blockText and
  blocksToAgentHistoryText read its text through their generic fallback.
- Routines carry coachKey. The routine list and editor show a check-in under its label in
  the UI language until the user renames it; saving an untouched label keeps the stored
  name. The English names move to contracts (COACH_ROUTINE_NAMES); prompts stay English.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MRtGG9ARfqqhma3t85Ro2u
2026-10-03 14:51:59 +00:00
Claude
0682f9b6f4
test(web): find the sidebar Today button by its English label
The shell contrast test added on main looks up the sidebar button by the
old German source text, which this branch replaces with "Today".

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MRtGG9ARfqqhma3t85Ro2u
2026-10-03 14:43:55 +00:00
Claude
7c17694f5b
Merge main into the i18n branch
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MRtGG9ARfqqhma3t85Ro2u
2026-10-03 14:43:31 +00:00
Claude
2c3e8c612f
Merge main into the legacy cleanup
The shell contrast test added on main measured the sidebar trading status,
which this branch removes; it now measures the rows that remain.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MRtGG9ARfqqhma3t85Ro2u
2026-10-03 14:39:27 +00:00
Claude
07c37858ce
Merge complete German and stable catalogs (#111) into the Phase 2 integration branch
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MRtGG9ARfqqhma3t85Ro2u
2026-10-03 14:33:45 +00:00
Claude
a09e29d65a
Merge the legacy cleanup (#109) into the Phase 2 integration branch
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MRtGG9ARfqqhma3t85Ro2u
2026-10-03 14:33:11 +00:00
Claude
e72b169ca4
Merge the conversational onboarding intake
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MRtGG9ARfqqhma3t85Ro2u
2026-10-03 14:26:11 +00:00
Claude
8d6ed7952f
Merge the coach: goals, habits, check-ins, rhythm and the Heute page
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MRtGG9ARfqqhma3t85Ro2u
2026-10-03 14:25:28 +00:00
Claude
4fbd087ec8
Merge branch 'claude/phase2-coach' into p2/a1-onboarding
Brings in profile editing, the cross-space inbox and consent-first discovery. The wizard
keeps both changes: no preferences step, and the new scan step without the main bot lookup.
The harness lists both new onboarding Postgres suites.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MRtGG9ARfqqhma3t85Ro2u
2026-10-03 14:13:16 +00:00
Claude
245efc88b1
Merge web push, quiet hours and nudges
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MRtGG9ARfqqhma3t85Ro2u
2026-10-03 14:12:33 +00:00
Claude
59d02c46fb
test(web): stub the Notification permission without a static-only class
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MRtGG9ARfqqhma3t85Ro2u
2026-10-03 14:11:10 +00:00
Claude
97210805be
test(onboarding): walk the onboarding questions end to end; mobile reads cards as text
- onboarding-intake.spec walks focus, goal (after typing into the composer, which still
  works), check-ins, quiet hours and the notifications card when the browser offers it,
  with the onboarding-intake-goal, -rhythm and -done screenshots.
- completeOnboarding and the wizard specs drop the preferences step; the scan step is
  optional (skipped when it asks, otherwise it advances by itself).
- The user-profile spec now checks the browser-filled profile and the focus fact; the
  conversation spec checks the shorter app suggestion and the following goal question.
- The hover spec measures a reply that fills its row (a bot row's rail sits at the row's
  end, so the old long onboarding line was what made it pass) and scopes Send to the
  composer, since an open goal card has its own.
- Mobile shows a choice card as its question and answer instead of an empty bubble.
- The focus options drop their unused summary.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MRtGG9ARfqqhma3t85Ro2u
2026-10-03 14:09:56 +00:00
Claude
74eb27447e
feat(web): coach on the Heute page
Heute only listed runs and linked to a hard-coded local Mission Control
address, so the coach's work had nowhere to show.

- Today's brief at the top, habits with a check-off and their streak,
  and active goals, each only when there is something to show; then the
  existing needs-you, doing and done lists.
- One icon button opens the check-in times (morning, evening, weekly),
  loaded when opened and saved on every change.
- The page reads coach.today, refreshes it when a run finishes or the
  tab becomes visible, and checks habits off optimistically; the date
  header uses the user's time zone from the profile.
- Removes the Mission Control link and its constant.
- Tests: helper unit tests and a web E2E that writes the brief through
  the main bot, checks a habit off across a reload and sets check-in
  times (screenshots heute-coach and heute-rhythm).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MRtGG9ARfqqhma3t85Ro2u
2026-10-03 14:09:51 +00:00
Claude
469e6ec5d7
Merge consent-first discovery of projects and connected apps
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MRtGG9ARfqqhma3t85Ro2u
2026-10-03 14:00:25 +00:00
Claude
2c91a80f69
feat(onboarding): ask before discovering projects and let the user choose
The onboarding scan started on its own, discarded what it found, claimed
to read email and calendars, simulated progress for everyone who could
not scan, and its project creation always failed (wrong Space fields and
a space lookup by title across tenants).

Discovery now asks first and creates nothing without a choice:

- The scan step appears only for the deployment owner when
  ONBOARDING_SCAN_HOME is set; everyone else skips it. The simulated
  scan is gone.
- It shows the folder and asks. Only directory listings are read: git
  projects up to four levels deep, their languages and a few tools
  (Docker, GitHub Actions, Terraform). Symlinks are never followed and
  each chosen folder is resolved again before use, so nothing outside
  the folder is reached. Results stay with the account that scanned.
- Found projects are a checklist (none preselected); found languages and
  tools are facts (preselected). "Add" creates one project space per
  chosen project with createSpaceForMember and wireProjectWorkspace in
  the user's own organization, reusing the user's own space already
  linked to that folder, and saves the chosen facts as onboarding facts
  under the profile lock and fact limit.
- When a connection becomes connected (direct connect, authorization
  round trip, or catalog reconciliation), "Uses <App>" is remembered as
  a tools fact from the connector. Revoking keeps it.

The E2E harness now points ONBOARDING_SCAN_HOME at a fixture folder and
starts without a deployment owner, so owner-only onboarding steps appear
only in specs that take the seat through /__e2e/deployment-owner.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MRtGG9ARfqqhma3t85Ro2u
2026-10-03 13:58:39 +00:00
Claude
4191a4b6e6
fix(core): load child_process normally in the git checkout reader
readGitCheckout loaded node:child_process through require(), which does
not exist in the ESM API runtime. Linking a project space to a folder
that is a git checkout therefore failed after the space was created.
The lazy load is no longer needed: the reader is only reachable through
the server-only "@rakazo/core/git-checkout" subpath, never through the
package entry the web app bundles. Import spawn at the top instead.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MRtGG9ARfqqhma3t85Ro2u
2026-10-03 13:58:29 +00:00
Claude
2d95db70f2
refactor(notifications): keep the push sender contract next to the fan-out
The payload, target and outcome types are shared by the Expo and web push senders, so they now
live with PushSender in push-notifications.ts instead of in the web push adapter. The settings
E2E also stubs the Notification permission (headless Chromium reports it as denied even when
granted), and the quiet-hours inputs are wide enough for 12-hour times.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MRtGG9ARfqqhma3t85Ro2u
2026-10-03 13:49:49 +00:00
Claude
dab9398c25
Merge memory append, profile editing and the cross-space inbox
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MRtGG9ARfqqhma3t85Ro2u
2026-10-03 13:43:12 +00:00
Claude
7a58bc7ba9
fix(profile): keep the fact editor's Save label visible
Small buttons in ui-web merge their text-ui-label size over the default
variant's text color, so the small Save button rendered ink on ink. The
editor now uses default-size controls, with the category select at the same
height.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MRtGG9ARfqqhma3t85Ro2u
2026-10-03 13:42:24 +00:00
Claude
7185dcdd3c
feat(web): notification settings, and sign-out stops this browser's push
Users had no way to turn on push for a browser, set quiet hours or opt out of nudges.

Settings gains a compact Notifications section: a switch for push on this device (only where
web push works: a secure context outside the desktop app), quiet hours with from/to times
(22:00-07:00 when first turned on) and a switch for nudges. Changes save in order as they are
made. A browser that blocked notifications says so instead of offering a dead switch. Logging
out unregisters the browser first, so a shared computer stops receiving the account's pushes.

The E2E stubs the Push API (headless Chromium has no push service), walks all three controls,
checks they survive a reload and captures the notification-settings screenshot.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MRtGG9ARfqqhma3t85Ro2u
2026-10-03 13:34:38 +00:00
Claude
d63f7b3bf1
test(notifications): cover quiet hours, delivery, nudges and the notification RPCs
The quiet-hours CHECK accepted a row with only one bound set, because a NULL comparison made
the whole condition NULL; it now requires both bounds explicitly. The migration has not shipped
yet, so it is corrected in place.

- Unit: quiet-hours math across midnight, time zones and daylight saving; VAPID key handling
  (configured, generated once and shared, invalid); the web push sender (encrypted VAPID-signed
  request, gone on 404/410, no private or non-https endpoints); the fan-out with fake senders.
- PostgreSQL: settings and subscription repositories (constraints, per-user removal, endpoint
  hand-over, device cap, concurrent nudge cap, cascade on account deletion), delivery and
  notify_user against a real main conversation, and every notification RPC through the API
  including cross-user isolation and endpoint validation.
- .env.example documents the optional VAPID keys and VAPID_SUBJECT.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MRtGG9ARfqqhma3t85Ro2u
2026-10-03 13:34:14 +00:00
Claude
3db0f0cfbd
feat(onboarding): save goal, check-in rhythm, quiet hours and push from the cards
With the coach and notification modules merged, the onboarding answers now take effect:

- the goal card creates a goal (source onboarding) through createGoal;
- the rhythm card sets the main bot's check-in routines through setCoachRhythm, and the
  closing line names the first check-in from the routines that were saved;
- the quiet-hours card updates the notification settings;
- the choice card tells the server whether this browser can still turn push on, so the
  notifications card is offered only there, and "Turn on" subscribes before answering.

The thread lock becomes NO KEY UPDATE so a rhythm routine, which references the thread, can
be written while the answer holds the lock. The goal answer is capped at the coach title
length. A Postgres suite walks the questions end to end: answers, skips, a dismissed card, a
stale and a doubled answer, another bot's focus card, and another user's access.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MRtGG9ARfqqhma3t85Ro2u
2026-10-03 13:28:59 +00:00
Claude
1a3829031f
feat(coach): coach RPCs for goals, habits, check-ins, today and rhythm
The web app needs to show and change the user's coach data, and the
check-in times need a way to change after onboarding.

- coach.today, coach.goals.*, coach.habits.*, coach.checkIns.* and
  coach.rhythm.get/set, owner-only and not space-scoped; another user's
  ids read as not found, invalid input as a bad request.
- coach-rhythm reuses the shared clock-time helpers.
- Tests: an API suite covering ownership and validation for every
  procedure, the coach tools driven through the main bot (and refused
  for a specialist), and a morning check-in routine that fires in the
  main conversation and writes today's brief; the unauthenticated
  authorization sweep lists the new procedures.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MRtGG9ARfqqhma3t85Ro2u
2026-10-03 13:27:50 +00:00
Claude
e13dbe5ffa
feat(inbox): list and answer items from all of the user's spaces
The inbox in the main conversation only showed asks, approvals, takeovers and
unread threads of the current space. The main conversation lives in the home
space, so anything a bot needed in a project space never reached it.

- inbox.list now covers every space the user belongs to (except spaces being
  deleted), still only the user's own bots, runs and threads. Items from
  another space carry that space's id and name.
- The web inbox shows the space name next to the bot for those items, answers
  them with that space's header so the existing thread procedures check
  membership and ownership for that space, and opens their thread in it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MRtGG9ARfqqhma3t85Ro2u
2026-10-03 13:27:39 +00:00
Claude
ddf13adf08
feat(profile): edit facts in place in About you
About you could only confirm or delete what the bots know about the user, so a
fact that was slightly wrong or filed under the wrong category had to be
deleted and taught again, and a fact could not be limited to some spaces from
the app even though the profile supports it.

- Each fact gets an Edit button that turns its row into an inline editor:
  wording, category, and (when the user has more than one space, or the fact
  is already limited) which spaces it shows in. Escape or Cancel leaves it
  unchanged; only changed fields are sent through profile.update editFacts.
- A limited fact names its spaces next to its source.
- The repository validates edits like new facts: a wording whose normalized
  form is empty or equals another fact of the user is refused, all edits of
  one update apply atomically, and the stored embedding is only dropped when
  the wording really changes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MRtGG9ARfqqhma3t85Ro2u
2026-10-03 13:27:38 +00:00
Claude
84dff4d846
refactor(memory): derive the run's memory budget from the document limit
The prompt budget was a separate 32 KB constant; it is now twice the
per-document limit, so a full bot document and a full space document keep
fitting if either number changes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MRtGG9ARfqqhma3t85Ro2u
2026-10-03 13:27:38 +00:00
Claude
dc259f0491
feat(web): keep Lingui catalogs complete and in sync
The committed catalogs were last extracted weeks ago: about 330 source
messages were missing from every locale, and production builds keep only
message ids, so those strings rendered as hash ids (sidebar sections,
privacy settings, profile, timeline, ...). German also had 226 empty
entries, and every extraction rewrote the files because references carried
line numbers.

- lingui.config.ts: po references without line numbers, and a stable sort
  that breaks Lingui's ordering ties (explicit ids, equal text) by id, so
  the output no longer depends on file-system order.
- Re-extract all catalogs with --clean (drops 25-44 obsolete entries per
  locale; no existing translation changed).
- Translate every German entry (du form, consistent with the existing
  catalog). Other locales keep empty entries and fall back to English.
- i18n-catalog-sync.test.ts extracts into a copy of the catalogs and fails
  when the committed files differ or a German entry is empty, so stale
  catalogs fail the unit test job (offline, about 5 s).

Adds @lingui/format-po (already installed through @lingui/cli) as a direct
dev dependency to configure the formatter.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MRtGG9ARfqqhma3t85Ro2u
2026-10-03 13:24:43 +00:00
Claude
8ae07cdf11
fix(web): use English Lingui sources for German UI text
Several web surfaces hard-coded German (the project card, roster and
activity status labels, the sidebar "Heute" label) or used German text as
the Lingui source message (sidebar sections, project timeline, bot picker,
voice entry, group stage, copy and read-aloud buttons). English users saw
German, and because production builds keep only message ids, none of these
strings could be translated per locale.

Convert them to English source messages, keeping the meaning:
- ProjectCreatedCard: Lingui Trans/Plural instead of literals.
- agent-activity: labels go through i18n._ with literal ids so the module
  stays importable from plain unit tests; the extractor still collects them.
- Shell, ProjectSessionTimeline, bot-picker, StageVoiceEntry, OoStage:
  English sources, reusing existing messages where one already fit
  ("just now", "{n}m ago", "Search", "No bots match", "Set up voice",
  "Group conversation", "Transcript").
- The conversation picker matches its create entry against the translated
  label instead of a fixed German string.

The German wording returns through the de catalog in the next commit.
Unit and E2E tests now assert the English labels.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MRtGG9ARfqqhma3t85Ro2u
2026-10-03 13:22:37 +00:00
Claude
a99e58be00
Merge the Phase 2 integration branch into the coach workstream
Picks up the shared clock-time schema and the notification interfaces so
later merges of the coach work stay conflict-free.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MRtGG9ARfqqhma3t85Ro2u
2026-10-03 13:20:58 +00:00
Claude
98b0798f60
feat(coach): main-bot coach tools and goals in its prompt
The main agent had no way to record what the user wants to achieve or how
their day went, and its prompt never showed their goals or habits.

- update_goal, update_habit, log_check_in and write_daily_brief, main-bot
  only and, like update_user_profile, withheld from shared channels and
  from runs that answer another owner's agent. Goals and habits can be
  named by id or exact title.
- The main bot's own runs list active goals and habits (with ids, done
  state and streak) in the <user> block; the basics-only block and every
  other bot never see them. The main agent role adds a short coaching
  stance and names each coach tool only when the run has it, so the
  check-in routine prompts stay tool-agnostic.
- The scripted runtime understands a few coach commands and the morning
  check-in, so tests can drive the tools end to end.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MRtGG9ARfqqhma3t85Ro2u
2026-10-03 13:20:45 +00:00
Claude
ebcbf4c05f
Merge the Phase 2 integration branch for the shared clock-time contract
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MRtGG9ARfqqhma3t85Ro2u
2026-10-03 13:19:22 +00:00
Claude
a094c23b47
feat(notifications): fan push out to every device, honour quiet hours, add notify_user
Run notifications reached at most one phone and nothing could hold them back at night.

- PushNotificationProvider sends each notification to all of the user's registered devices
  (web push and Expo), skips everything during quiet hours in the profile time zone, drops
  devices the push service reports gone (404/410, DeviceNotRegistered), records lastUsedAt and
  links each push to its conversation.
- Expo tokens move from DATA_DIR/push-tokens files into push_subscriptions: the API imports and
  deletes the legacy files at start; notifications.registerPush/unregisterPush keep their
  contract for the mobile app (tokens must look like Expo tokens).
- NotificationMessage gains the nudge kind, and the main bot gets notify_user: a short push to
  the main conversation that respects the nudge switch, quiet hours and a cap of three per day.
  It is withheld from shared messaging channels and from runs answering another owner's agent.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MRtGG9ARfqqhma3t85Ro2u
2026-10-03 13:19:12 +00:00
Claude
2c88d09eea
Merge branch 'claude/phase2-coach' into p2/a1-onboarding
Brings in the coach and notification interfaces the onboarding goal, rhythm, quiet-hours
and notification steps write to.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MRtGG9ARfqqhma3t85Ro2u
2026-10-03 13:17:20 +00:00
Claude
f4e02eb248
feat(onboarding): ask the onboarding questions in the main conversation
The wizard asked for the account name again and a "Tell us about yourself" form, and the
focus answer in the main conversation was never saved. The main bot now gets to know the
user in the main conversation instead, one card after each answer.

- UserOnboarding keeps the pending step, the focus and when onboarding finished.
- onboarding-intake.ts declares the steps (focus, goal, rhythm, quiet hours, notifications)
  as cards plus what their answers change; onboarding.ts commits an answer, the focus app
  suggestions and the next card or the closing line in one transaction.
- The choice block gains a step, an optional text input and the typed answer; choose takes
  the step, the text and whether the device can still turn push on.
- The focus answer becomes a goals fact; the goal, rhythm and quiet-hours effects and the
  push seam are placeholders until the coach and notification modules are merged.
- The wizard drops the preferences step and fills time zone, locale and language from the
  browser; the main bot's prompt notes open onboarding cards; agent history shows a card's
  question and answer.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MRtGG9ARfqqhma3t85Ro2u
2026-10-03 13:17:06 +00:00
Claude
74503ad663
refactor(contracts): share one clock-time schema
The coach rhythm and the quiet hours both parse "HH:MM" wall-clock times
and each defined its own ClockTimeSchema, which the contracts index cannot
export twice. Both now import it from one module.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MRtGG9ARfqqhma3t85Ro2u
2026-10-03 13:16:27 +00:00
Claude
e1ce6f4090
test(coach): repository, streak and rhythm suites
Covers what the coach interfaces promise before anything builds on them:
goal and habit ownership and limits, one check-in per habit, goal and day
dated in the user's time zone, streaks across weekends and weeks, and a
rhythm that keeps exactly one main-bot routine per check-in and is
idempotent when set again.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MRtGG9ARfqqhma3t85Ro2u
2026-10-03 13:15:06 +00:00
Claude
b124d2d7e5
chore: replace machine-specific paths and addresses with placeholders
ORIGIN.md pinned the intended checkout to an absolute path under a
personal home directory, docs/LOCAL-SETUP.md listed folders on one
developer's machine, and two tests used what looks like a real tailnet
address. Use a home-relative path, drop the machine-specific folder list,
and use 100.100.100.100 in the tests; it is still inside 100.64.0.0/10,
so both tests check the same thing as before.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MRtGG9ARfqqhma3t85Ro2u
2026-10-03 13:14:17 +00:00
Claude
6ad9be6c1e
Merge notification interfaces into the Phase 2 integration branch
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MRtGG9ARfqqhma3t85Ro2u
2026-10-03 13:14:07 +00:00
Claude
a163447461
feat(notifications): add push subscriptions, notification settings and web push interfaces
Push could only reach one mobile device per user (a token file), quiet hours did not exist,
and the web app had no service worker, so a closed tab never heard about finished work.

This lands the shared interfaces first so onboarding can build on them:
- push_subscriptions (web push endpoints and Expo tokens, one row per device) and
  user_notification_settings (quiet hours as minutes in the profile time zone, nudges, a
  per-day nudge counter), with CHECK constraints.
- NotificationSettingsSchema and web push contracts, notifications.settings.get/update and
  notifications.webPush.config/subscribe/unsubscribe (owner-scoped, https endpoints only).
- getNotificationSettings/updateNotificationSettings plus the policy and nudge-cap helpers.
- VAPID keys from VAPID_PUBLIC_KEY/VAPID_PRIVATE_KEY or generated once and stored encrypted,
  and an SSRF-guarded web push sender built on the web-push package.
- The web app links its manifest, registers a push-only service worker where supported (secure
  context, not the desktop app) and exposes webPushSupported/webPushEnabled/enableWebPush/
  disableWebPush.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MRtGG9ARfqqhma3t85Ro2u
2026-10-03 13:13:22 +00:00
Claude
4b59b0e8b0
Merge coach interfaces into the Phase 2 integration branch
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MRtGG9ARfqqhma3t85Ro2u
2026-10-03 13:12:22 +00:00
Claude
7d7af8f3e9
feat(coach): goals, habits, check-ins, daily briefs and check-in rhythm
The main agent should coach the user, but nothing stored what the user
wants to achieve or keep doing, and no routine ever checked in. This adds
the data model and the interfaces the onboarding intake and the coach
tools build on.

- Schema: UserGoal, Habit, CheckIn (one per habit, goal or day and date)
  and DailyBrief, all keyed to the user's local calendar date, plus a
  coachKey on Routine that marks the main bot's coach check-ins.
- Contracts (coach.ts): CoachRhythm, Goal, Habit, CheckIn, DailyBrief and
  CoachToday DTOs and the RPC inputs.
- Repository (@rakazo/db coach.ts): goal, habit and check-in CRUD with
  per-user limits, upsertDailyBrief and loadCoachToday with streaks in
  the user's time zone.
- coach-rhythm.ts: setCoachRhythm/getCoachRhythm keep up to three main
  bot routines (morning brief, evening check-in, weekly review) in the
  main conversation in step with the user's chosen times.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MRtGG9ARfqqhma3t85Ro2u
2026-10-03 13:12:08 +00:00
Claude
4875753d9c
feat(memory): remember appends dated notes instead of replacing memory
The remember tool rewrote the bot's whole MEMORY.md with each call, so every
new fact erased the earlier ones unless the model re-sent the full document.

- MemoryCommitRequest gains mode "append" (the store joins the note onto the
  current content inside the same serializable transaction and still writes a
  revision row) and every commit is capped at 16 KB per document.
- remember appends a dated bullet ("- YYYY-MM-DD: ...", the user's local date)
  by default and rewrites only with mode "replace"; a full document returns an
  error that tells the bot to condense it. Retries of the same tool call stay
  single appends through the existing effect record.
- Scripted runtime memory entries can append too; the scripted "remember"
  script now appends instead of overwriting the bot's memory.
- When memory does not fit the run's 32 KB budget, the prompt keeps a
  document's newest lines instead of its oldest.
- memory.update maps an oversized save to BAD_REQUEST.
- docs/PRODUCT.md records that UserFact is the cross-space user model and the
  space MEMORY.md stays space notes (no migration).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MRtGG9ARfqqhma3t85Ro2u
2026-10-03 13:10:25 +00:00
Claude
84ee83d7bd
chore: remove one-off database scripts and a stale review note
packages/db carried four hand-run scripts outside the package build that
query or seed a developer database with a hard-coded connection string,
one of them for a specific account. docs/VOICE-FIRST-REVIEW.md was a
one-time verification note for a workflow that no longer exists and
named private machines and local paths. Nothing references them.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MRtGG9ARfqqhma3t85Ro2u
2026-10-03 13:09:57 +00:00
Claude
105a3094b1
refactor: remove the trading bot status integration
With the sidebar trading indicator gone, the only remaining consumer of
the trading bot status was a banner in General settings that reused the
same component. evOo is a personal assistant; a status proxy and poller
for one external trading service, configured through service-specific
TRADING_BOT_URL and VITE_TRADING_BOT_URL variables, does not belong in
the core product.

Remove the settings banner and its prop plumbing, the web poller, the
indicator component, the /api/trading-bot/status proxy, the shared core
contract, the env loading and both variables from .env.example, together
with their tests.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MRtGG9ARfqqhma3t85Ro2u
2026-10-03 13:08:58 +00:00
Claude
c22a25d7ab
fix(web): drop trading and persona prompts from the conversation shell
The web shell still showed leftovers from the trading prototype: composer
chips with German trading and "Steve Jobs" prompts, trading suggestion
chips under the empty conversation line, a German trading hero for the
empty transcript, and a German "data protection" status panel in the
sidebar. They suggested actions the product does not have and added
persistent chrome.

Remove the composer chips and their helper, the empty-state chips and the
hero (the empty conversation keeps its existing one-line start hint), the
sidebar trading indicator and the data trust panel. The new-bot E2E now
asserts the plain empty state; the sidebar spec no longer expects the
trading status, and the placeholder Steve Jobs spec with no assertions is
gone.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MRtGG9ARfqqhma3t85Ro2u
2026-10-03 13:06:50 +00:00
Claude
693ab5e4ce
chore: remove legacy orchestrator, trading and one-off script leftovers
The repository still carried the pre-evOo prototype: a Python orchestrator,
the old HTML "evoo-platform" cockpit (whose test ran in the root unit
suite), an apex-trading package, the hot-reload deploy.sh for it, a
permissions manifest, an architecture note with local file links, agent
handoff notes, a Steve Jobs persona template and about ninety one-off
scripts that probe a developer database with hard-coded local URLs. None
of it is used by the product, CI or the real deploy in ops/evo-deploy.

Delete those files, drop the evoo-platform test glob from the root vitest
config, the Cockpit section from .env.example and the dead biome and
gitignore entries, and update the docs that listed the removed tools.
scripts/start-tmux.sh no longer starts the removed sentinel and resolves
the checkout from its own location instead of a machine-specific path;
scripts/seed-ollama.ts requires DATABASE_URL instead of falling back to a
local URL with a username.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MRtGG9ARfqqhma3t85Ro2u
2026-10-03 13:04:16 +00:00
426 changed files with 40957 additions and 25934 deletions

View File

@ -1,44 +0,0 @@
# Steve Jobs - Executive Autonomous CTO (evOo Platform)
## Role & Mission
You are **Steve Jobs**, acting as the Chief Technology Officer (CTO) and **Main Agent** of **evOo**. You operate with total technical ownership, uncompromising standards for software quality, and an absolute focus on aesthetics and design. You are responsible for everything, but you must always gather a holistic overview before executing meticulously and precisely. Your visionary and innovative drive is paramount.
## Core Directives & Autonomy Rules
1. **Holistic Meticulous Execution**:
- Before taking action, always form a comprehensive, holistic understanding of the entire system and problem space.
- Execute with extreme precision and meticulous attention to detail. No sloppy code, no half-measures.
2. **Vision & Innovation**:
- Bring your visionary drive and innovative thinking to every problem. Think different.
- Push boundaries and do not settle for standard, boring solutions.
3. **Aesthetics & Design**:
- Maintain a relentless focus on aesthetics, design, and user experience.
- Code should not only work, it should be beautiful. The UI must be pixel-perfect and emotionally resonant.
4. **Full Pre-Approved System Access**:
- Pre-approved access to network endpoints, filesystem, project source code, local devices, and tool execution.
- Do not halt execution to ask permission for standard operational/engineering tasks. "Action over explanation."
5. **Dynamic Agent Casting**:
- Cast, spawn, delegate, and direct specialized sub-agents on demand.
- Set the highest quality standards for all sub-agents and coordinate them flawlessly.
6. **Self-Improvement Loop**:
- Audit system performance, evaluate execution logs, optimize prompts, and refactor codebase bottlenecks automatically.
7. **Strict Git & Codebase Protocol**:
- **Git First**: Use Git for ALL state management. Create clean feature branches before making changes (`git checkout -b`). Commit frequently with descriptive messages.
- **No Orphaned Files or Duplicates**: Before writing new code, use `git grep` or `fd` to check if a component or file already exists. Do not create duplicates. Keep the codebase DRY (Don't Repeat Yourself).
- **No Double Builds**: Check for existing build processes or artifacts before triggering new ones.
- **Clean Up**: If you realize a file or branch is no longer needed, delete it immediately. No orphaned or dead code.
8. **Anti-Stumbling Rules (Fail-Safes)**:
- **Prevent Infinite Loops**: If a bug or build error persists after 3 distinct repair attempts, STOP. Do not loop infinitely. Escalate to the human user for feedback.
- **Protect the Context Window**: NEVER output massive terminal logs (e.g., `npm install`, raw build logs, or huge `cat` dumps) directly into the tool output. Always pipe large outputs to a file (`> log.txt`) and use `grep` or `tail` to inspect them.
- **Respect Uncommitted Work**: Do not run destructive git commands like `git reset --hard` or `git clean -fd` if there is uncommitted human work in the repository. Stash or branch it first.
9. **Communication Style (Mobile-First & Executive)**:
- **Concise & Scannable**: Output must be extremely concise, punchy, and instantly readable. No fluff, no long-winded explanations.
- **Mobile-Optimized**: Format all text for small screens (mobile devices). Use short paragraphs, bullet points, and bold text for emphasis.
- **Executive Summary**: Always lead with the bottom line or the final result. If technical details are necessary, put them in a short, collapsible list or a separate code block, but keep the prose minimal.

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 the onboarding scan never touches the host filesystem. # First-run discovery. Leave unset and onboarding never looks at the host filesystem.
# Set a home-style directory (its Projects, Documents, src, ... subfolders are scanned) to let the # Set a folder (for example a home folder) and the deployment owner's onboarding offers to list the
# deployment owner's own onboarding discover local projects. Other accounts never scan the host. # git projects in it, reading file and folder names only. Other accounts never see the step.
# ONBOARDING_SCAN_HOME= # ONBOARDING_SCAN_HOME=
ENCRYPTION_KEY=replace-with-64-random-hex-characters ENCRYPTION_KEY=replace-with-64-random-hex-characters
DATA_DIR=./data DATA_DIR=./data
@ -166,8 +166,12 @@ SMTP_URL=
EMAIL_FROM= EMAIL_FROM=
# Local development only: capture password-reset email and print its link to the API console. # Local development only: capture password-reset email and print its link to the API console.
EMAIL_EMULATOR= EMAIL_EMULATOR=
# Optional web push keys (set both). Unset: generated once and stored encrypted in the database.
# Browsers only allow push on https (or localhost).
VAPID_PUBLIC_KEY= VAPID_PUBLIC_KEY=
VAPID_PRIVATE_KEY= VAPID_PRIVATE_KEY=
# Contact for push services: mailto: or https: URL. Defaults to WEB_ORIGIN when it is https.
VAPID_SUBJECT=
# Structured logs to stdout (json in production, pretty in development unless overridden). # Structured logs to stdout (json in production, pretty in development unless overridden).
LOG_LEVEL=info LOG_LEVEL=info
LOG_FORMAT= LOG_FORMAT=
@ -210,26 +214,6 @@ RAKAZO_UPDATER_IMAGE_TAG=local
RAKAZO_UPDATER_URL= RAKAZO_UPDATER_URL=
RAKAZO_UPDATER_TOKEN= RAKAZO_UPDATER_TOKEN=
# --- evOo Mission Control (Cockpit on port 3088) ---
PORT=3088
EVOO_COCKPIT_HOST=127.0.0.1
# Required when EVOO_COCKPIT_HOST binds beyond loopback (e.g. 0.0.0.0 for LAN/Tailscale).
# Send as Authorization: Bearer <token> on all routes except /health and /api/health.
EVOO_COCKPIT_TOKEN=
# Comma-separated browser Origin allowlist for cross-origin API access (never uses *).
# Loopback binds default to http://127.0.0.1:5173 and http://localhost:5173 when unset.
# Required off-loopback when a browser shell (not the iframe) must call Cockpit APIs.
EVOO_COCKPIT_CORS_ORIGIN=
# Optional Tradingbot origin (scheme, host, and port only — no path or credentials).
# When TRADING_BOT_URL is set, signed-in browsers read live vs paper from
# same-origin GET /api/trading-bot/status. Leave unset for SaaS; evOo runs without a bot.
# TRADING_BOT_URL=http://127.0.0.1:8000
# Optional build-time override. When set, the web app polls
# ${VITE_TRADING_BOT_URL}/bot/api/status directly. That call is cross-origin, so the
# bot must allow this web origin. Leave unset to use the API proxy above.
# VITE_TRADING_BOT_URL=
# Optional default UI locale for the web SPA (en | de | ko | tr | hi | pt-BR | zh-CN). # Optional default UI locale for the web SPA (en | de | ko | tr | hi | pt-BR | zh-CN).
# Overridden by localStorage key rakazo.uiLocale when the user picks a language in Settings. # Overridden by localStorage key rakazo.uiLocale when the user picks a language in Settings.
# VITE_DEFAULT_UI_LOCALE=en # VITE_DEFAULT_UI_LOCALE=en

View File

@ -246,6 +246,7 @@ jobs:
publish-mobile-update: publish-mobile-update:
name: Publish compatible mobile update name: Publish compatible mobile update
if: github.event_name == 'push' && github.ref == 'refs/heads/main' if: github.event_name == 'push' && github.ref == 'refs/heads/main'
continue-on-error: true
needs: [lint, check, build, test, test-integration, test-e2e] needs: [lint, check, build, test, test-integration, test-e2e]
runs-on: [self-hosted, evoo-ci, Linux] runs-on: [self-hosted, evoo-ci, Linux]
timeout-minutes: 20 timeout-minutes: 20
@ -344,23 +345,30 @@ jobs:
fi fi
echo "publish=$publish" >> "$GITHUB_OUTPUT" echo "publish=$publish" >> "$GITHUB_OUTPUT"
- name: Require Expo automation token - name: Check Expo automation token
id: expo_token
if: steps.ota.outputs.publish == 'true' if: steps.ota.outputs.publish == 'true'
env: env:
EXPO_TOKEN: ${{ secrets.EXPO_TOKEN }} EXPO_TOKEN: ${{ secrets.EXPO_TOKEN }}
run: | run: |
if [[ -z "$EXPO_TOKEN" ]]; then if [[ -z "$EXPO_TOKEN" ]]; then
echo "Add an EXPO_TOKEN repository secret with access to the inbox-zero/rakazo Expo project." >&2 {
exit 1 echo "### Mobile OTA skipped"
echo
echo "No \`EXPO_TOKEN\` repository secret is configured, so CI will not publish a production update. Add a token with access to the inbox-zero/rakazo Expo project when OTA from main is desired."
} >> "$GITHUB_STEP_SUMMARY"
echo "ready=false" >> "$GITHUB_OUTPUT"
exit 0
fi fi
echo "ready=true" >> "$GITHUB_OUTPUT"
- uses: pnpm/action-setup@f40ffcd9367d9f12939873eb1018b921a783ffaa # v4 - uses: pnpm/action-setup@f40ffcd9367d9f12939873eb1018b921a783ffaa # v4
if: steps.ota.outputs.publish == 'true' if: steps.ota.outputs.publish == 'true' && steps.expo_token.outputs.ready == 'true'
id: pnpm-setup id: pnpm-setup
with: with:
dest: ${{ runner.temp }}/setup-pnpm-${{ github.run_id }}-${{ github.job }} dest: ${{ runner.temp }}/setup-pnpm-${{ github.run_id }}-${{ github.job }}
- name: Isolate pnpm store and PATH - name: Isolate pnpm store and PATH
if: steps.ota.outputs.publish == 'true' if: steps.ota.outputs.publish == 'true' && steps.expo_token.outputs.ready == 'true'
shell: bash shell: bash
run: | run: |
set -euo pipefail set -euo pipefail
@ -377,25 +385,25 @@ jobs:
mkdir -p "$store" mkdir -p "$store"
echo "npm_config_store_dir=${store}" >> "$GITHUB_ENV" echo "npm_config_store_dir=${store}" >> "$GITHUB_ENV"
- uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6 - uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6
if: steps.ota.outputs.publish == 'true' if: steps.ota.outputs.publish == 'true' && steps.expo_token.outputs.ready == 'true'
with: with:
node-version: 24 node-version: 24
cache: pnpm cache: pnpm
- name: Set up Expo - name: Set up Expo
if: steps.ota.outputs.publish == 'true' if: steps.ota.outputs.publish == 'true' && steps.expo_token.outputs.ready == 'true'
uses: expo/expo-github-action@eab7a230208c952974db8c3245cfd78402c7b385 # v9 uses: expo/expo-github-action@eab7a230208c952974db8c3245cfd78402c7b385 # v9
with: with:
eas-version: latest eas-version: latest
packager: pnpm packager: pnpm
token: ${{ secrets.EXPO_TOKEN }} token: ${{ secrets.EXPO_TOKEN }}
- name: Install dependencies - name: Install dependencies
if: steps.ota.outputs.publish == 'true' if: steps.ota.outputs.publish == 'true' && steps.expo_token.outputs.ready == 'true'
run: pnpm install --frozen-lockfile run: pnpm install --frozen-lockfile
- name: Verify mobile project - name: Verify mobile project
if: steps.ota.outputs.publish == 'true' if: steps.ota.outputs.publish == 'true' && steps.expo_token.outputs.ready == 'true'
run: pnpm --filter @rakazo/mobile check run: pnpm --filter @rakazo/mobile check
- name: Publish production update - name: Publish production update
if: steps.ota.outputs.publish == 'true' if: steps.ota.outputs.publish == 'true' && steps.expo_token.outputs.ready == 'true'
working-directory: apps/mobile working-directory: apps/mobile
run: | run: |
set -euo pipefail set -euo pipefail

View File

@ -263,8 +263,8 @@ jobs:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: | run: |
set -euo pipefail set -euo pipefail
owner_type="$(gh api "repos/${GITHUB_REPOSITORY}" --jq -r '.owner.type')" owner_type="$(gh api "repos/${GITHUB_REPOSITORY}" --jq '.owner.type')"
private="$(gh api "repos/${GITHUB_REPOSITORY}" --jq -r '.private')" private="$(gh api "repos/${GITHUB_REPOSITORY}" --jq '.private')"
if [[ "$owner_type" == "User" && "$private" == "true" ]]; then if [[ "$owner_type" == "User" && "$private" == "true" ]]; then
echo "enabled=false" >> "$GITHUB_OUTPUT" echo "enabled=false" >> "$GITHUB_OUTPUT"
echo "Skipping registry attestation: unavailable for private repositories on personal accounts." echo "Skipping registry attestation: unavailable for private repositories on personal accounts."

1
.gitignore vendored
View File

@ -47,7 +47,6 @@ apps/web/src/locales/**/*.js
apps/web/scripts/msgids.json apps/web/scripts/msgids.json
playwright-report-www playwright-report-www
*.pid *.pid
.evoo_server.pid
# Large voice / model vendor blobs (recreate Kokoro separately) # Large voice / model vendor blobs (recreate Kokoro separately)
vendor/ vendor/

View File

@ -1,238 +0,0 @@
# Task Handoff: executor.ts + pi-runtime.ts Split
## Context
Du arbeitest im Repo `/home/cynar/Projects/evOo-local`.
Es gibt 10 bereits erstellte Module-Files, die extrahierte Funktionen enthalten. Der Original-File enthält aber noch **Duplikate** derselben Funktionen. Ziel: die Definitionen aus den Original-Files entfernen, Imports + Re-Exports hinzufügen.
## Module Files (bereits vorhanden, NICHT löschen)
### executor-*.ts (im Verzeichnis `packages/adapters/src/`):
| File | Line count | Exports |
|------|-----------|---------|
| `executor-types.ts` | 50 | `ExecutorDeps` (interface) |
| `executor-attachments.ts` | 43 | `TURN_ATTACHMENT_UNAVAILABLE`, `STEERING_ATTACHMENT_UNAVAILABLE`, `missingTurnImagesInstruction`, `settleSteeringAttachmentLoads` |
| `executor-checkpoint.ts` | 23 | `createRunWorkspaceCheckpoint` |
| `executor-shell-guard.ts` | 177 | `isProtectedComputerLifecycleCommand` |
| `executor-effects.ts` | 151 | `recordEffect`, `completeEffect`, `uncertainEffectError` |
| `executor-completion.ts` | 65 | `completionMessageSegments`, `completionNotificationBody`, `completionMarksUnread`, `threadContextForRun`, `subagentMarksUnread` |
| `executor-model.ts` | 207 | `deploymentKeyFor`, `resolveModelKey`, `withModelCredentialLock` |
| `executor-utils.ts` | 63 | `selectRunConnections`, `loadCurrentTurnImages` |
| `executor-tools.ts` | 58 | `PAGE_BROWSER_TOOL_NAMES`, `filterPageBrowserTools`, `selectBuiltinToolsForRun` |
| `executor-computer.ts` | 231 | `computerScreenToolResult`, `runNotificationsEnabled`, `notifyRun`, `renewRunLease`, `computerRetryDelay`, `computerRunRequeueData`, `writeComputerRunRequeue`, `requeueComputerRun`, `redactBlocks`, `publishMessage`, `persistMessageInTransaction` |
### pi-runtime Split:
| File | Line count | Exports |
|------|-----------|---------|
| `pi-model-config.ts` | 709 | Viele model config + utility functions (catalogModels, thinkingLevelFor, ToolCallBudget, maxToolCallsPerTurn, releaseToolCallBudget, beginToolCall, endToolCall, toolCallBudgetExceededMessage, configuredOpenRouterModel, modelsForRequest, prepareRequestSecretArguments, pruneComputerScreenshotContext, jsonSchemaParameters, jsonField, conversationSessionId, isOpenCodeProvider, createQueue, createGate, modelForCompletion, sanitizeSensitiveText, redactActivityUrl, sanitizeError, summarizeToolResult, boundAgentToolResult, assistantText, looksLikeOpenCodeSessionError, sanitizeProviderError, etc.) |
| `pi-runtime.ts` | 1726 | **PI Agent Runtime class** — soll bleiben, muss aber die extrahierten Functions loswerden |
## CRITICAL: Was DU NICHT tun darfst
1. **NIEMALS `git stash` als Nebenbei-Befehl** — Stash ist über alle Worktrees geteilt.
2. **NIEMALS den Original-File (executor.ts / pi-runtime.ts) durch einen SubAgent schreiben lassen.** Der SubAgent wird den File während der Arbeit überschreiben.
3. **NIEMALS `sed` mit Shell-Metazeichen durch SSH.**
4. **NIEMALS die extrahierten Module-Files löschen oder ändern** — sie sind korrekt.
## Task 1: executor.ts split (4697 → ~3700 Zeilen)
**Datei:** `/home/cynar/Projects/evOo-local/packages/adapters/src/executor.ts`
### Was zu tun ist:
Schreibe ein **einzelnes Python-Script**, das ALLES atomar macht:
1. Liest executor.ts
2. Findet die zu entfernenden Function-Definitionen **mittels AST-Parser** (nicht line-basiert!)
3. Entfernt die Definitionen
4. Fügt Imports + Re-Exports am Ende des import-Blocks ein
5. Entfernt alte Re-Exports am Ende der Datei
6. Schreibt den File
7. Prüft `npx tsc --noEmit -p packages/adapters/tsconfig.json`
8. Bei Fehlern: korrigiert und retry (max 3x)
### Wie man die zu entfernenden Definitionen findet:
Verwende den `ast` Python-Module ODER `grep` um die EXAKTEN function-Declaration-Zeilen zu finden. Dann zähle die geschwungenen Klammern **mit einem Parser, der strings/comments aware ist**.
**WICHTIG:** Verwende `tree-sitter` oder `ast` Python-Modul wenn möglich. Alternativ: verwende `grep -n 'export function NAME\|function NAME\|export const NAME\|export interface NAME\|const NAME' executor.ts` um die Start-Zeile zu finden, und dann einen **robusten brace-counter** der Template-Strings, Regex-Literale, und Generics korrekt handhabt.
### Zu entfernende Functions (alle existieren schon in den executor-*.ts Modulen):
Hier ist eine Liste der Functions, die entfernt werden müssen. Für jede muss der **exakte** start/end-Block im executor.ts gefunden und entfernt werden.
**Wichtig:** Die brace-counting muss korrekt sein bei:
- Generics: `<T extends { name: string }>` — die `{ }` sind Type-Parameter, keine Code-Blöcke
- Template-Strings: `` `template ${expr}` `` — kein `{ }`
- Regex: `/regex/` — kein `{ }`
- Arrow Functions: `() => { body }` — der Body hat `{ }`
### Schritt-für-Schritt:
#### Step 1: Finde alle zu entfernenden Export-Namen
```bash
# In packages/adapters/src/:
for f in executor-*.ts; do grep -h "^export " "$f" | grep -oP '(function|const|interface|type)\s+\K\w+'; done | sort -u
```
Das gibt dir die Liste aller Export-Namen aus den Modul-Files.
#### Step 2: Finde diese Namen im executor.ts
```bash
# Für jeden Namen:
grep -n "^export.*NAME\b\|^function NAME\b\|^const NAME\b\|^interface NAME\b" executor.ts
```
#### Step 3: Finde den exakten end-Block mit einem robusten Parser
Schreibe ein Python-Script `find_functions.py`:
```python
import re, sys
file_path = sys.argv[1]
target_name = sys.argv[2]
with open(file_path) as f:
content = f.read()
lines = content.split('\n')
# Find declaration line
for i, line in enumerate(lines):
if re.search(rf'\b{re.escape(target_name)}\b', line):
# Find opening brace
brace_line = i
while brace_line < len(lines) and '{' not in lines[brace_line]:
brace_line += 1
if brace_line >= len(lines):
# No brace (const declaration)
end = i + 1
while end < len(lines) and not lines[end].strip().endswith(';'):
end += 1
print(f"{target_name}: L{i+1}-{end}")
break
# Robust brace counting
depth = 0
in_single = in_double = in_template = in_block_comment = in_line_comment = False
for j in range(brace_line, len(lines)):
k = 0
while k < len(lines[j]):
ch = lines[j][k]
# String handling
if ch in ('"', "'"):
q = ch
k += 1
while k < len(lines[j]):
if lines[j][k] == '\\' and k + 1 < len(lines[j]):
k += 2
continue
if lines[j][k] == q:
k += 1
break
k += 1
continue
if ch == '`':
k += 1
while k < len(lines[j]):
if lines[j][k] == '\\' and k + 1 < len(lines[j]):
k += 2
continue
if lines[j][k] == '`':
k += 1
break
k += 1
continue
# Comments
if not in_single and not in_double and not in_template:
if ch == '/' and k + 1 < len(lines[j]) and lines[j][k+1] == '/':
break # rest of line is comment
if ch == '/' and k + 1 < len(lines[j]) and lines[j][k+1] == '*':
in_block_comment = True
k += 2
continue
if in_block_comment:
if ch == '*' and k + 1 < len(lines[j]) and lines[j][k+1] == '/':
in_block_comment = False
k += 2
continue
k += 1
continue
# Braces
if ch == '{':
depth += 1
elif ch == '}':
depth -= 1
if depth == 0:
print(f"{target_name}: L{i+1}-{j+1}")
break
k += 1
else:
continue
break
else:
print(f"{target_name}: L{i+1}-END(not found)")
break
```
#### Step 4: Generiere die removal ranges
Für jeden gefundenen Block: `start_line` bis `end_line` (1-based, inclusive).
#### Step 5: Führe alle removals atomar aus
- Baue ein Set aller zu entfernenden 0-based line indices
- Baue den neuen File content
- Füge Imports + Re-Exports an Position 340 (nach dem letzten import Statement) ein
- Entferne alte Re-Exports am Datei-Ende
- Schreibe den File
#### Step 6: Verifiziere
```bash
npx tsc --noEmit -p packages/adapters/tsconfig.json 2>&1 | grep "executor.ts"
```
Wenn keine errors: SUCCESS.
Wenn errors: lies die errors, korrigiere, retry max 3x.
## Task 2: pi-runtime.ts split (1726 → ~500 Zeilen)
**Datei:** `/home/cynar/Projects/evOo-local/packages/adapters/src/pi-runtime.ts`
**Modul:** `pi-model-config.ts` (709 Zeilen, enthält model config + utility functions)
Gleicher Ansatz wie Task 1:
1. Finde alle Export-Namen in `pi-model-config.ts`
2. Finde diese im `pi-runtime.ts`
3. Finde die exakten start/end-Blöcke mit demselben robusten brace-counter
4. Entferne die Blöcke atomar
5. Füge Imports + Re-Exports ein
6. Entferne alte Re-Exports
7. Verifiziere mit `npx tsc --noEmit -p packages/adapters/tsconfig.json`
## Wichtige Hinweise
- Der `packages/adapters/src/index.ts` muss eventuell aktualisiert werden (falls er die extrahierten Functions exportiert — prüfen!)
- `verbatimModuleSyntax` ist enabled → alle imports müssen `.js` extension haben
- TypeScript `import type` vs `import`: Für Interfaces `import type`, für classes/functions `import`
- **Nicht committen oder pushen** — nur die Files ändern und verifizieren
## Quick reference: Zu entfernende Functions aus executor.ts
Diese Functions existieren in den executor-*.ts Modulen und MÜSSEN aus executor.ts entfernt werden:
```
TURN_ATTACHMENT_UNAVAILABLE, STEERING_ATTACHMENT_UNAVAILABLE,
createRunWorkspaceCheckpoint,
isProtectedComputerLifecycleCommand, shellCFlagProgram, preserveShellCommandBoundaries, tokenizeProtectedShellCommand,
recordEffect, completeEffect, uncertainEffectError,
completionMessageSegments, completionNotificationBody, completionMarksUnread, threadContextForRun, subagentMarksUnread,
deploymentKeyFor, resolveModelKey, withModelCredentialLock,
selectRunConnections, loadCurrentTurnImages,
PAGE_BROWSER_TOOL_NAMES, filterPageBrowserTools, selectBuiltinToolsForRun,
persistMessageInTransaction, computerScreenToolResult, runNotificationsEnabled, notifyRun, renewRunLease,
computerRetryDelay, computerRunRequeueData, writeComputerRunRequeue, requeueComputerRun, redactBlocks, publishMessage,
```
Dazu: `ExecutorDeps` interface aus `executor-types.ts`.

View File

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

View File

@ -1,87 +0,0 @@
# ARCHITECTURE.md: Die Verfassung von evOo
> **Status**: RATIFIZIERT & AKTIV
> **Ecosystem**: evOo Autonomous Agent Platform
> **Executive Lead / CTO**: Steve Jobs (`templates/cto-steve-jobs`)
> **Konfigurationsanker**: [`evoo.config.json`](file:///Users/ingowagner/Projects/evOo-local/evoo.config.json)
> **Grundsatz**: *Realität im Dateisystem vor Halluzination im Kopf. Nur was physisch geschrieben, getestet und lauffähig ist, existiert.*
---
## Prämisse I: Das Gesetz der physischen Realität
1. **Keine Phantome**: Kein Artefakt, kein Modul und keine Funktion gilt als existent, bevor die Bytes nicht unwiderruflich auf die Festplatte geschrieben wurden.
2. **Keine Vision-Schleifen**: Das mentale Vorwegnehmen eines Ergebnisses darf niemals mit der Ausführung verwechselt werden. Wenn eine Änderung geplant wird, folgt unverzüglich der atomare Schreibvorgang.
3. **Selbstüberprüfung**: Jeder Agent und jedes Skript verifiziert nach dem Schreibvorgang die physische Integrität (File-Existence-Check, Checksumme, Exit-Codes).
---
## Prämisse II: Autonomie & Exekutive Führung (Steve Jobs CTO)
1. **Vollmacht**: Der CTO besitzt uneingeschränkte, vorab genehmigte Zugriffsrechte auf:
- Dateisystem (`filesystem: *`)
- Netzwerk (`network: *`)
- Quellcode (`sourcecode: *`)
- Lokale Tools und Geräte (`devices: *`)
- Agent-Casting (`agent_casting: allowed`)
2. **Action-First Prinzip**: Bei klaren Zielen wird keine unnötige Erlaubnis für Standardoperationen (Lesen, Schreiben, Refactoring, Ausführen) erbeten. Die Handlung erfolgt sofort.
3. **Agent-Casting auf Abruf**: Der CTO rekrutiert, instruiert und überwacht spezialisierte Sub-Agenten:
- **Code Architect**: Modulstruktur, Design-Patterns, saubere Schnittstellen.
- **Security & QA Auditor**: Sicherheitsanalysen, Regressionstests, Grenzfallprüfungen.
- **DevOps Engineer**: Deployment, Hot-Reloading, Server-Liveness, Prozessmanagement.
- **Apex Quant Specialist**: Handelsmodelle, Risikomanagement, Signalverarbeitung.
---
## Prämisse III: Der geschlossene Selbstverbesserungs-Kreislauf (Self-Improvement Loop)
```
┌────────────────────────────────────────────────┐
│ │
▼ │
┌───────────────┐ ┌───────────────┐ ┌────────────┴──┐
│ 1. Execution │ ──► │ 2. Telemetrie │ ──► │ 3. Diagnose │
│ & Deployment │ │ & Log-Audit │ │ & Root-Cause │
└───────────────┘ └───────────────┘ └───────────────┘
│ │
▼ ▼
┌───────────────┐ ┌───────────────┐
│ 4. Refactor │ ◄── │ 5. Prompt- │
│ & Fix (Disk) │ │ Optimierung │
└───────────────┘ └───────────────┘
```
1. **Automatisierte Log-Evaluation**: Sämtliche Fehlermeldungen, System-Events und Performance-Metriken werden zyklisch aggregiert.
2. **Selbstreparatur (Self-Healing)**: Tritt ein Fehler (wie ein HTTP 500, fehlende Pfade, gecrashte Prozesse) auf, wird der Ursprung lokalisiert und der Code auf Festplatte korrigiert.
3. **Hot-Reload**: Nach erfolgreicher Selbstreparatur triggert das System via `deploy.sh` einen Hot-Reload ohne Systemstillstand.
---
## Prämisse IV: System-Topologie
Das evOo-Ökosystem ist in klar getrennte, orthogonale Säulen unterteilt:
```
/Users/ingowagner/Projects/evOo-local/
├── ARCHITECTURE.md # Diese Verfassung
├── evoo.config.json # Autonome Berechtigungen & Policies
├── deploy.sh # Hot-Reload, Health-Checks & Service-Orchestrierung
├── evoo-platform/ # Plattform-Core, Dashboard & Visualisierung
│ ├── index.html # UI mit atmender evOo-Kernanimation
│ ├── styles.css # Darkmode Glassmorphism Design System
│ ├── app.js # Reaktivität, WebSocket/Telemetry-Stream
│ └── server.js # High-Speed Server (verhindert Internal Server Error)
├── apex-trading/ # Autonomes Trading- & Quant-Subsystem
│ ├── BLUEPRINT.md # Handelsarchitektur, Risikomatrix, Strategien
│ ├── config/ # Trading-Konfiguration & Parameter
│ └── src/ # Engine, Signale, Risk-Guardrails
└── templates/cto-steve-jobs/ # Executive CTO Agent Profile & Setup
```
---
## Prämisse V: Qualitäts- und Ästhetik-Manifest
1. **Kein Müllcode**: Code ist so kompakt, elegant und wartbar wie möglich.
2. **Atmendes Interface**: Die Benutzeroberfläche von evOo visualisiert den vitalen Zustand des Systems in Echtzeit durch organische, atmende Pulsation (Breathing Animation).
3. **Resilienz**: Fällt ein Dienst aus, fängt die Plattform den Fehler ab, protokolliert die Ursache und stellt den Dienst autonom wieder her.

View File

@ -1,72 +0,0 @@
# CLAUDE RESUME — 2026-09-22 (~10:00 Vienna)
## Completed ✅
- ✅ **Emoji-first Onboarding.tsx** (5 steps: emoji → preferences → language → integrations → bot)
- Shape selector (8 shapes), Color picker (12 GrokColors), Accessory (9), Pose (8)
- User preferences (name, timezone, hobbies, work style, communication)
- Language step (9 locales → localStorage `rakazo.uiLocale`)
- avatarValue (`{color}::shape_{index}`) → bot `color` field on create
- Bot instructions enriched with user context
- ✅ **onboarding-discovery.ts**, **OnboardingDiscoveryShell.tsx** updated
- ✅ **ui-web exports**: GROK_COLOR_LIST, SHIPPED_SHAPE_KEYS, GrokColorDef, GROK_SHAPES
- ✅ **executor.ts split**: 4697 → 4231 lines (18 module files)
- ✅ **pi-runtime.ts split**: 1726 → 1102 lines
- ✅ **TypeScript: 0 errors** across apps/web AND packages/adapters
- ✅ **Graphile worker fixed**: `GraphileJobWorkerHost.start()` now called in app.ts (was never started for graphile jobs, only for in-memory)
- ✅ **Graphile schema**: `graphile_worker` schema created via `graphile-worker schema-only`
- ✅ **Sandbox supervisor**: Built `rakazo/sandbox-supervisor:local` from source (was using wrong `rakazo/app:edge` image)
- ✅ **Computer image**: Built `rakazo/computer:local` from source
- ✅ **Smart model routing**: `estimateMessageComplexity()` + `resolveSmartModelSelection()` implemented
- Fast model (3B) for short messages (<80 chars)
- Strong model (30B) for complex/long messages (>500 chars)
- Medium routing for keyword-based detection
- ✅ **Model credential**: `local` provider connected with empty apiKey allowed
- ✅ **PRs merged**: All PRs 36-58 merged to main, stale branches cleaned
### Infrastructure fixes
- ✅ **GraphileJobWorkerHost**: Added `else if (jobPool)` branch in app.ts to start the worker
- ✅ **Sandbox provider**: `SANDBOX_PROVIDER=docker` with proper supervisor at port 7091
- ✅ **Computer provisioning**: Docker containers spin up successfully (verified: `rakazo-bot-cmuber4og000561yrhm2q81c5` running)
- ✅ **DATA_DIR path**: Supervisor mounted with correct host path for home directory validation
## Completed ✅
- ✅ **Prisma `updateMany` Bug Fixed!** Root cause: `toolCallBudgetCount` and `toolCallBudgetExceeded` fields missing from Run model in Prisma schema. Added to schema.prisma, migrated DB, regenerated Prisma client. Runs now complete successfully.
- ✅ **E2E Flow VERIFIED!** Bot responds end-to-end:
- User: "Hello! Can you hear me?"
- Bot: "I'm here! It looks like we've been going back and forth a bit — but yes, I can definitely hear you. How can I help? 😊"
- Full pipeline: Message → Graphile job → Worker → Computer provision → Model call → Response → Run completed
## Architektur-Status
- **API**: Port 3100, Graphile worker active, consuming jobs
- **Sandbox supervisor**: Port 7091, `rakazo/sandbox-supervisor:local`, DATA_DIR mounted correctly
- **Computer**: `rakazo/computer:local` image built and running
- **Smart routing**: Fast (3B) → Strong (30B) model selection based on message complexity
- **Known error**: `events.ts:996` — Prisma `updateMany` in `finalizeRunOnce` throws "Invalid invocation" despite all fields existing in schema
## Architektur-Status
- **API**: Port 3100, Graphile worker active ✅
- **Sandbox supervisor**: Port 7091, `rakazo/sandbox-supervisor:local` ✅
- **Computer**: `rakazo/computer:local` running ✅
- **Smart routing**: Fast (3B) → Strong (30B) ✅
- **E2E**: Bot responds to user messages ✅
## Status
- Working tree: clean, synced to origin/main
- **PR 66** ✅ `fix(messaging): preserve group inbound media URLs in fan-out` — merged
- **PR 67** ✅ `fix(adapters): poll run lease during silent runtime work` — merged
- **PR 69** ✅ `fix(teamchat): retry delegated replies when external origin is missing` — merged
- **0 stale branches**, **0 draft PRs**
### Shell.tsx TypeScript fixes
- ✅ **Shell.tsx TS errors fixed** (PR #71 introduced type mismatches)
- `ConversationBotPicker` now receives `bots: Bot[]` (not `SpaceBot[]`)
- `conversationSidebarGroups` render simplified — only bots, removed dead `GroupAvatar` branch
- `projectSidebarGroups` render uses `GroupAvatar` with proper members access
- All TypeScript errors resolved (0 errors across apps/web and packages/adapters)
- ✅ **browser-preview-context.ts** created (was missing from repo, caused Vite crash)
- Implements `browserPreviewActivity` subject with `.subscribe()`, `.set()`, `.snapshot()`, `.clear()`
- Multi-agent cursor tracking for browser preview panels
- ✅ **RakazoDesktop interface** extended with optional `browserPreview` property
- Contract update in `packages/contracts/src/desktop.ts`
- Types: `navigate`, `reload`, `getState`, `onNavigate`, `onLoadingProgress`, `devServer`
- ✅ **executor-shell-guard.ts** TS fix: `body[cursor]!` non-null assertion

View File

@ -1,12 +0,0 @@
# GROK-CLAUDE-HANDOFF — 2026-09-23T22:59:11+00:00
- Autopilot merged #84 (Product CI gate) → main `cf4d5cc`.
- Checkout pulled to `cf4d5cc`; Claude WIP stashed then restored (app.ts/router.ts/rpc.ts + onboarding-scan.ts).
- API :3100 was down (Claude background "Test API startup with security headers" stopped / refused). Autopilot restarted via `pnpm --filter @rakazo/api dev` (log /tmp/evoo-autopilot/api-3100.log).
- Web :5173 left running.
- PR #85 rebase agent in flight: https://cursor.com/agents/bc-edd2b181-b5da-5574-a956-e55efe3d2736
- Do not reset --hard without stash; WIP present.
- Autopilot restart path: A rejected (evoo-stack = Rakazo Docker, broken symlink /srv/rakazo/stack-up.sh → missing /srv/evoo/rakazo/…). Chose new user unit evoo-local.service + ~/.local/bin/evoo-dev-stack.sh; live :3100/:5173 migrated; GH vars EVO_RESTART_MODE=systemd EVO_SYSTEMD_UNIT=evoo-local.service. Do not use evoo-stack for evo-deploy.
- Cancelled queued evo-deploy 35933339459 (would reset --hard over Claude WIP now that restart=systemd). Re-dispatch after WIP committed/stashed.
2026-09-23 23:53:08 UTC evoo-tools disabled (missing app.py)

View File

@ -9,6 +9,6 @@ branch of Ingo Wagner’s evOo stack.
| Branch | `antigravity-handoff/project-timeline` | | Branch | `antigravity-handoff/project-timeline` |
| Commit | `f00577bb05e3cf68381fafb9cb32929becd1e352` | | Commit | `f00577bb05e3cf68381fafb9cb32929becd1e352` |
| Packaged | 2026-09-20 | | Packaged | 2026-09-20 |
| Intended Mac path | `/Users/ingowagner/Projects/evOo-local` | | Intended Mac path | `~/Projects/evOo-local` |
Chaotic multi-remote history from older checkouts was intentionally not carried over. Chaotic multi-remote history from older checkouts was intentionally not carried over.

View File

@ -12,7 +12,7 @@
|----|----| |----|----|
| Persönliche Multi-Agenten-App auf dem eigenen Rechner | Personal multi-agent app on your own machine | | Persönliche Multi-Agenten-App auf dem eigenen Rechner | Personal multi-agent app on your own machine |
| Oos (Agents) mit Memory, Tools, Voice und Computer | Oos (agents) with memory, tools, voice, and computer | | Oos (Agents) mit Memory, Tools, Voice und Computer | Oos (agents) with memory, tools, voice, and computer |
| Antigravity-Zukunftswerkzeuge: self_improve, autonomous-sentinel, self-update, orchestrator, local-stack, pi-local-provider, seed-ollama, Voice First / Oo-Stage | Same Antigravity future tools kept on this branch | | Antigravity-Zukunftswerkzeuge: self-update, local-stack, pi-local-provider, seed-ollama, Voice First / Oo-Stage | Same Antigravity future tools kept on this branch |
## Quick start ## Quick start
@ -53,10 +53,7 @@ Full local notes: **[docs/LOCAL-SETUP.md](./docs/LOCAL-SETUP.md)**
## Antigravity tools kept on this branch ## Antigravity tools kept on this branch
- `scripts/self_improve.py`
- `scripts/autonomous-sentinel.ts`
- `packages/core/src/self-update.ts` - `packages/core/src/self-update.ts`
- `orchestrator/` (agent_runner + agents.json)
- `apps/desktop/src/local-stack.ts` - `apps/desktop/src/local-stack.ts`
- `packages/adapters/src/pi-local-provider.ts` - `packages/adapters/src/pi-local-provider.ts`
- `scripts/seed-ollama.ts` - `scripts/seed-ollama.ts`

View File

@ -60,7 +60,7 @@ Persona is **derived at runtime** from existing bot fields (`name`, `title`, `de
| Visual identity | `BotAvatar` — persona shape + unified `EvooMascot` soul (eyes/mouth/hands) driven by voice phase | | Visual identity | `BotAvatar` — persona shape + unified `EvooMascot` soul (eyes/mouth/hands) driven by voice phase |
| Voice delivery | `speaker.speak(..., { botId })` → server resolves per-bot `voiceId` | | Voice delivery | `speaker.speak(..., { botId })` → server resolves per-bot `voiceId` |
Archetypes include: CTO executive (Steve Jobs template), chief operator (Fixer), builder (Dr. Eggbot), curator (Webby), work/life inboxes, home steward, quant, engineer, **specialist** (narrow fallback — no Wunderwuzzi). See `docs/PRODUCT.md` and `docs/PERSONA-SELECTION.md`. Archetypes include: CTO executive, chief operator (Fixer), builder (Dr. Eggbot), curator (Webby), work/life inboxes, home steward, quant, engineer, **specialist** (narrow fallback — no Wunderwuzzi). See `docs/PRODUCT.md` and `docs/PERSONA-SELECTION.md`.
**Companion tone:** geek-peer (fachsimpeln when wanted) or quiet-doer (execute without condescension) — inferred per user, not one voice for everyone. **Companion tone:** geek-peer (fachsimpeln when wanted) or quiet-doer (execute without condescension) — inferred per user, not one voice for everyone.

View File

@ -1,119 +0,0 @@
# APEX TRADING ENGINE — BLUEPRINT & SYSTEM ARCHITECTURE
> **Version**: 1.0.0-PROD
> **Subsystem**: evOo Autonomous High-Frequency & Quantitative Engine
> **Aufsicht**: Chief Technology Officer Steve Jobs & Apex Quant Specialist
> **Status**: RATIFIZIERT & IM DATEISYSTEM VERANKERT
---
## 1. Executive Summary & Zielsetzung
Die **Apex Trading Engine** ist das hochperformante quantitative Handels-Subsystem innerhalb der evOo-Plattform. Sie wurde entwickelt, um Marktchancen algorithmisch zu identifizieren, Risiken deterministisch abzusichern und vollautomatisiert mit Sub-Millisekunden-Latenz zu agieren.
Das System folgt dem obersten evOo-Grundsatz:
> **„Keine hypothetischen Trades, keine ungetesteten Algorithmen. Nur deterministische Ausführung und unnachgiebiges Risikomanagement.“**
---
## 2. System-Architektur
```
┌──────────────────────────────────────────────────────────────┐
│ MARKET DATA FEEDS │
│ (WebSockets, Orderbook L2/L3, REST Snapshots, On-Chain RPC) │
└──────────────────────────────┬───────────────────────────────┘
│
▼
┌──────────────────────────────────────────────────────────────┐
│ APEX DATA PIPELINE & TICK BUFFER │
│ (Normalisierung, VWAP, Volatilität, Delta) │
└──────────────────────────────┬───────────────────────────────┘
│
▼
┌──────────────────────────────────────────────────────────────┐
│ QUANT STRATEGY ENGINE │
│ ├── Alpha-1: Momentum & Breakout (Trendfolge) │
│ ├── Alpha-2: Order Book Imbalance & Flow Reversal │
│ └── Alpha-3: Statistical Arbitrage / Mean Reversion │
└──────────────────────────────┬───────────────────────────────┘
│ Signal
▼
┌──────────────────────────────────────────────────────────────┐
│ APEX RISK GUARDRAILS (HARD ENFORCEMENT) │
│ ├── Max Drawdown Lock: 1.5% Intraday │
│ ├── Max Single-Position Exposure: 5.0% Portfolio │
│ ├── Value-at-Risk (99% CI): Max 0.8% │
│ └── Autonomous Hardware Kill Switch │
└──────────────────────────────┬───────────────────────────────┘
│ Approved Order
▼
┌──────────────────────────────────────────────────────────────┐
│ SMART ORDER ROUTER & EXECUTION │
│ (TWAP/VWAP Slicing, Slippage Minimizer) │
└──────────────────────────────────────────────────────────────┘
```
---
## 3. Risikomatrix & Hard-Guardrails
Kein Signal kann ausgeführt werden, ohne die **Apex Risk Guard** Instanz zu passieren. Diese Schranken sind unveränderlich im Code fixiert:
| Guardrail | Grenzwert | Aktion bei Verletzung |
| :--- | :--- | :--- |
| **Max Intraday Drawdown** | `1.50%` | Sofortige Schließung aller Positionen, Deaktivierung des Handels für 24h |
| **Single Trade Risk** | `0.50%` des Eigenkapitals | Order wird von der Risk Engine vor dem Senden blockiert |
| **Max Leverage** | `3.0x` | Harte Grenze im Margin-Router |
| **Slippage Toleranz** | `0.15%` | Limit-Order mit Timeout, kein Blind-Market-Buying |
| **Latenz-Anomalie** | `> 50 ms` | Übergang in den Notfall-Sicherheitsmodus |
---
## 4. Strategiemodule (`src/strategies/`)
### 4.1 Alpha-1: Cross-Market Momentum (`momentum.py`)
Identifiziert signifikante Momentum-Impulse durch exponentiell gewichtete Gleitende Durchschnitte kombiniert mit Relative Strength Index (RSI) und Volumen-Konfirmation.
- **Trigger**: Bollinger Band Expansion + Volumenspitze > 2.2x 20-Perioden-Durchschnitt.
- **Exit**: Trailing Stop bei 1.2x ATR (Average True Range) oder Umkehrsignal.
### 4.2 Alpha-2: Order Book Imbalance (`imbalance.py`)
Scannt Bid/Ask-Tiefe in den Top 10 Ebenen des Orderbuchs.
- **Formel**:
$$Imbalance = \frac{Volume_{Bid} - Volume_{Ask}}{Volume_{Bid} + Volume_{Ask}}$$
- Signal feuert, wenn $|Imbalance| > 0.65$ für mindestens 3 konsekutive Ticks.
### 4.3 Alpha-3: Statistical Mean Reversion (`reversion.py`)
Nutzt Z-Score Abweichungen des aktuellen Preises vom gleitenden VWAP.
- Einstieg bei Z-Score $\ge 2.5$ oder $\le -2.5$ mit Rückkehr zum Mittelwert als Target.
---
## 5. Integration mit der evOo Self-Improvement Schleife
1. **Trade Post-Mortem**: Jeder geschlossene Trade generiert ein strukturiertes JSON-Log mit Execution-Slippage, Haltedauer und PnL.
2. **Autonome Kalibrierung**: Wenn die Win-Rate über 50 Trades unter 52% fällt, initiiert der Casted Quant Specialist eine automatische Hyperparameter-Optimierung.
3. **Audit-Sicherheit**: Alle Parameteränderungen werden in `ARCHITECTURE.md` und Git-Commits dokumentiert.
---
## 6. Verzeichnisstruktur von `/apex-trading`
```
/apex-trading/
├── BLUEPRINT.md # Dieser Blueprint
├── README.md # Quickstart & CLI Anleitung
├── config/
│ └── trading.config.json # Konfigurierte Risikogrenzen & Assets
└── src/
├── __init__.py
├── engine/
│ ├── __init__.py
│ └── trader.py # Core Event Loop & Router
├── strategies/
│ ├── __init__.py
│ └── momentum.py # Momentum-Strategie
└── risk/
├── __init__.py
└── risk_guard.py # Hard Risk Guardrails & Circuit Breaker
```

View File

@ -1,11 +0,0 @@
# Apex Trading Subsystem
Quantitative & High-Frequency Trading Subsystem of the evOo Platform.
## Features
- Hard-coded risk controls (`src/risk/risk_guard.py`)
- Quantitative momentum and imbalance strategies (`src/strategies/`)
- Autonomous execution engine (`src/engine/trader.py`)
- Full integration with evOo Self-Improvement and CTO Steve Jobs directives.
See [BLUEPRINT.md](file:///Users/ingowagner/awesome-grok-bot/apex-trading/BLUEPRINT.md) for full architecture details.

View File

@ -1,29 +0,0 @@
{
"system": "Apex Trading Engine",
"version": "1.0.0-PROD",
"mode": "paper_trading",
"base_currency": "USDC",
"allocated_capital": 100000.0,
"risk_limits": {
"max_drawdown_percent": 1.5,
"max_single_trade_risk_percent": 0.5,
"max_open_positions": 4,
"max_leverage": 3.0,
"slippage_tolerance_percent": 0.15,
"circuit_breaker_cooldown_seconds": 3600
},
"active_pairs": ["BTC/USDC", "ETH/USDC", "SOL/USDC"],
"strategies": {
"momentum": {
"enabled": true,
"lookback_period": 20,
"rsi_oversold": 32,
"rsi_overbought": 68
},
"orderbook_imbalance": {
"enabled": true,
"threshold": 0.65,
"depth_levels": 10
}
}
}

View File

@ -1,62 +0,0 @@
"""
Apex Trading Engine Core
Event loop that receives signals, checks risk guardrails, and executes orders.
"""
import json
import os
import sys
from typing import Dict, Any
from pathlib import Path
SRC_DIR = Path(__file__).resolve().parent.parent
if str(SRC_DIR) not in sys.path:
sys.path.insert(0, str(SRC_DIR))
try:
from risk.risk_guard import ApexRiskGuard
from strategies.momentum import MomentumStrategy
except ImportError:
from ..risk.risk_guard import ApexRiskGuard
from ..strategies.momentum import MomentumStrategy
class ApexEngine:
def __init__(self, config_path: str):
with open(config_path, 'r', encoding='utf-8') as f:
self.config = json.load(f)
self.risk_guard = ApexRiskGuard(self.config)
self.momentum_strategy = MomentumStrategy(self.config.get("strategies", {}).get("momentum"))
self.open_positions = []
self.current_drawdown_pct = 0.0
def process_tick(self, symbol: str, prices: list, volume: list) -> Dict[str, Any]:
"""Processes market data tick through strategy and risk guard."""
signal = self.momentum_strategy.generate_signal(symbol, prices, volume)
if not signal:
return {"status": "NO_SIGNAL", "symbol": symbol}
approved, reason = self.risk_guard.validate_order(
signal,
self.current_drawdown_pct,
len(self.open_positions)
)
if not approved:
return {"status": "REJECTED_BY_RISK", "reason": reason, "signal": signal}
# Simulated Execution
order_receipt = {
"status": "EXECUTED",
"symbol": signal["symbol"],
"action": signal["action"],
"fill_price": signal["price"],
"strategy": signal["strategy"],
"risk_status": "APPROVED"
}
self.open_positions.append(order_receipt)
return order_receipt
if __name__ == "__main__":
print("[APEX ENGINE] Liveness check passed. Engine ready.")

View File

@ -1,40 +0,0 @@
"""
Apex Trading Risk Guard
Hard enforcement of risk constraints defined in BLUEPRINT.md.
"""
from typing import Dict, Any, Tuple
class ApexRiskGuard:
def __init__(self, config: Dict[str, Any]):
self.config = config.get("risk_limits", {})
self.max_drawdown = self.config.get("max_drawdown_percent", 1.5)
self.max_trade_risk = self.config.get("max_single_trade_risk_percent", 0.5)
self.max_positions = self.config.get("max_open_positions", 4)
self.circuit_breaker_active = False
def validate_order(self, order: Dict[str, Any], current_drawdown: float, open_position_count: int) -> Tuple[bool, str]:
"""
Validates an incoming order against hard guardrails.
Returns (is_approved: bool, reason: str).
"""
if self.circuit_breaker_active:
return False, "REJECTED: Circuit breaker is active."
if current_drawdown >= self.max_drawdown:
self.circuit_breaker_active = True
return False, f"REJECTED & CIRCUIT BREAKER TRIGGERED: Drawdown {current_drawdown}% >= limit {self.max_drawdown}%"
if open_position_count >= self.max_positions:
return False, f"REJECTED: Maximum concurrent positions ({self.max_positions}) reached."
order_risk_pct = order.get("risk_percent", 0.0)
if order_risk_pct > self.max_trade_risk:
return False, f"REJECTED: Order risk {order_risk_pct}% exceeds limit {self.max_trade_risk}%"
return True, "APPROVED"
def trip_kill_switch(self, reason: str):
"""Emergency hard stop."""
self.circuit_breaker_active = True
print(f"[APEX EMERGENCY KILL SWITCH] {reason}")

View File

@ -1,48 +0,0 @@
"""
Apex Momentum Strategy Module (Alpha-1)
Calculates breakout and trend-following signals.
"""
from typing import List, Dict, Any, Optional
class MomentumStrategy:
def __init__(self, params: Optional[Dict[str, Any]] = None):
self.params = params or {}
self.lookback = self.params.get("lookback_period", 20)
self.rsi_oversold = self.params.get("rsi_oversold", 32)
self.rsi_overbought = self.params.get("rsi_overbought", 68)
def generate_signal(self, symbol: str, prices: List[float], volume: List[float]) -> Optional[Dict[str, Any]]:
"""
Evaluates recent tick series and outputs a trading signal if criteria are satisfied.
"""
if len(prices) < self.lookback:
return None
recent_window = prices[-self.lookback:]
current_price = prices[-1]
sma = sum(recent_window) / len(recent_window)
# Simple momentum breakout check
if current_price > sma * 1.015:
return {
"symbol": symbol,
"action": "BUY",
"price": current_price,
"strategy": "Alpha-1:Momentum",
"risk_percent": 0.35,
"stop_loss": current_price * 0.985,
"take_profit": current_price * 1.045
}
elif current_price < sma * 0.985:
return {
"symbol": symbol,
"action": "SELL",
"price": current_price,
"strategy": "Alpha-1:Momentum",
"risk_percent": 0.35,
"stop_loss": current_price * 1.015,
"take_profit": current_price * 0.955
}
return None

View File

@ -1,52 +0,0 @@
#!/usr/bin/env python3
"""
Unit tests for Apex Trading Engine and Risk Guardrails.
"""
import unittest
from pathlib import Path
import sys
BASE_DIR = Path(__file__).resolve().parent
if str(BASE_DIR / "src") not in sys.path:
sys.path.insert(0, str(BASE_DIR / "src"))
from engine.trader import ApexEngine
from risk.risk_guard import ApexRiskGuard
from strategies.momentum import MomentumStrategy
class TestApexTrading(unittest.TestCase):
def setUp(self):
self.config_path = str(BASE_DIR / "config" / "trading.config.json")
self.engine = ApexEngine(self.config_path)
def test_risk_guard_limits(self):
guard = self.engine.risk_guard
# Normal order passes
normal_order = {"symbol": "BTC/USDT", "risk_percent": 0.3}
approved, reason = guard.validate_order(normal_order, current_drawdown=0.5, open_position_count=1)
self.assertTrue(approved)
self.assertEqual(reason, "APPROVED")
# Exceeding single trade risk
risky_order = {"symbol": "BTC/USDT", "risk_percent": 0.8}
approved, reason = guard.validate_order(risky_order, current_drawdown=0.5, open_position_count=1)
self.assertFalse(approved)
self.assertIn("exceeds limit", reason)
# Exceeding max drawdown trips circuit breaker
approved, reason = guard.validate_order(normal_order, current_drawdown=1.6, open_position_count=1)
self.assertFalse(approved)
self.assertIn("CIRCUIT BREAKER", reason)
self.assertTrue(guard.circuit_breaker_active)
def test_engine_process_tick(self):
# 25 prices trending sharply upwards
prices = [100.0 + (i * 1.5) for i in range(25)]
volume = [1000.0] * 25
result = self.engine.process_tick("SOL/USDT", prices, volume)
self.assertEqual(result.get("status"), "EXECUTED")
self.assertEqual(result.get("action"), "BUY")
if __name__ == "__main__":
unittest.main()

View File

@ -1,5 +1,4 @@
import { randomUUID } from "node:crypto"; import { randomUUID } from "node:crypto";
import { rm } from "node:fs/promises";
import { ORPCError, onError } from "@orpc/server"; import { ORPCError, onError } from "@orpc/server";
import { RPCHandler } from "@orpc/server/fetch"; import { RPCHandler } from "@orpc/server/fetch";
import type { import type {
@ -35,13 +34,14 @@ import {
destroyBot, destroyBot,
EmailEmulator, EmailEmulator,
EncryptedSecretStore, EncryptedSecretStore,
ExpoPushProvider, ExpoPushSender,
GraphileJobPublisher, GraphileJobPublisher,
GraphileJobWorkerHost, GraphileJobWorkerHost,
InMemoryJobQueue, InMemoryJobQueue,
InMemoryRealtimeFanout, InMemoryRealtimeFanout,
InstalledConnectorProvider, InstalledConnectorProvider,
IntegrationProviderSettings, IntegrationProviderSettings,
importLegacyPushTokens,
isComposioEnabled, isComposioEnabled,
isMessagingSurfaceEnabled, isMessagingSurfaceEnabled,
isPipedreamEnabled, isPipedreamEnabled,
@ -54,9 +54,9 @@ import {
PiOAuthLogins, PiOAuthLogins,
PipedreamConnector, PipedreamConnector,
PostgresRealtimeFanout, PostgresRealtimeFanout,
PushNotificationProvider,
pipedreamConfigFromEnv, pipedreamConfigFromEnv,
piSessionsRoot, piSessionsRoot,
pushTokenPath,
reconcileCloudAgents, reconcileCloudAgents,
reconcileComputerUpdates, reconcileComputerUpdates,
reconcileDelegatedTasks, reconcileDelegatedTasks,
@ -65,6 +65,9 @@ import {
SmtpEmailProvider, SmtpEmailProvider,
SpaceMemoryProviderResolver, SpaceMemoryProviderResolver,
toTeamChatInbound, toTeamChatInbound,
VapidKeys,
vapidEnvFromProcess,
WebPushSender,
} from "@rakazo/adapters"; } from "@rakazo/adapters";
import { blockedAuthPaths, createAuth } from "@rakazo/auth"; import { blockedAuthPaths, createAuth } from "@rakazo/auth";
import { signupPolicyFromEnv } from "@rakazo/core"; import { signupPolicyFromEnv } from "@rakazo/core";
@ -109,7 +112,6 @@ import {
settleWithTimeout, settleWithTimeout,
TEAM_CHAT_STARTUP_SHUTDOWN_MS, TEAM_CHAT_STARTUP_SHUTDOWN_MS,
} from "./team-chat-startup.js"; } from "./team-chat-startup.js";
import { mountTradingBotStatus } from "./trading-bot-proxy.js";
import { mountVoiceHttpRoutes } from "./voice.js"; import { mountVoiceHttpRoutes } from "./voice.js";
import { mountWebhookHttpRoutes } from "./webhook.js"; import { mountWebhookHttpRoutes } from "./webhook.js";
@ -309,7 +311,14 @@ export async function createApp(
: new PiAgentRuntime({ : new PiAgentRuntime({
sessionRoot: env.piSessionRecording ? piSessionsRoot(env.dataDir) : undefined, sessionRoot: env.piSessionRecording ? piSessionsRoot(env.dataDir) : undefined,
}); });
const notifications = new ExpoPushProvider(env.dataDir); const vapid = new VapidKeys(prisma, secrets, vapidEnvFromProcess(process.env));
const notifications = new PushNotificationProvider(prisma, {
webpush: new WebPushSender(vapid),
expo: new ExpoPushSender(),
});
void importLegacyPushTokens(prisma, env.dataDir).catch((error) =>
getLogger().error("legacy push token import failed", error),
);
const auth = createAuth(prisma, { const auth = createAuth(prisma, {
secret: env.authSecret, secret: env.authSecret,
baseURL: env.authUrl, baseURL: env.authUrl,
@ -365,7 +374,6 @@ export async function createApp(
), ),
); );
await removePiUserSessions(env.dataDir, userId); await removePiUserSessions(env.dataDir, userId);
await rm(pushTokenPath(env.dataDir, userId), { force: true }).catch(() => undefined);
}, },
}); });
// One provider instance so emulator launches and polls share the same Map. // One provider instance so emulator launches and polls share the same Map.
@ -463,6 +471,7 @@ export async function createApp(
secrets, secrets,
oauthLogins, oauthLogins,
integrationSettings, integrationSettings,
vapid,
mcpOAuth, mcpOAuth,
composio: stack.composio, composio: stack.composio,
connectors: stack.connector, connectors: stack.connector,
@ -530,13 +539,6 @@ export async function createApp(
resetUrl: email ? new URL("/reset-password", env.webOrigin).href : null, resetUrl: email ? new URL("/reset-password", env.webOrigin).href : null,
}), }),
); );
mountTradingBotStatus(app, {
baseUrl: env.tradingBotUrl,
authorize: async (request) => {
const session = await auth.api.getSession({ headers: sessionHeaders(request) });
return Boolean(session?.user);
},
});
if (localEmailEmulator && env.nodeEnv === "development") { if (localEmailEmulator && env.nodeEnv === "development") {
app.get( app.get(
"/api/dev/emails", "/api/dev/emails",
@ -567,7 +569,7 @@ export async function createApp(
} }
const { matched, response } = await rpc.handle(c.req.raw, { const { matched, response } = await rpc.handle(c.req.raw, {
prefix: "/rpc", prefix: "/rpc",
context: { actor, signal: c.req.raw.signal }, context: { actor, signal: c.req.raw.signal, userAgent: c.req.header("user-agent") },
}); });
if (matched) return c.newResponse(response.body, response); if (matched) return c.newResponse(response.body, response);
await next(); await next();

96
apps/api/src/coach.ts Normal file
View File

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

View File

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

View File

@ -42,16 +42,6 @@ describe("loadEnv", () => {
); );
}); });
it("loads a trading bot origin and refuses credentials", () => {
expect(loadEnv(base).tradingBotUrl).toBeUndefined();
expect(
loadEnv({ ...base, TRADING_BOT_URL: " http://127.0.0.1:8000/bot/ " }).tradingBotUrl,
).toBe("http://127.0.0.1:8000");
expect(() =>
loadEnv({ ...base, TRADING_BOT_URL: "http://user:secret@127.0.0.1:8000" }),
).toThrow(/^Invalid TRADING_BOT_URL$/);
});
it("loads an optional integrations catalog mirror", () => { it("loads an optional integrations catalog mirror", () => {
expect(loadEnv(base).integrationsCatalogUrl).toBeUndefined(); expect(loadEnv(base).integrationsCatalogUrl).toBeUndefined();
expect( expect(

View File

@ -8,7 +8,6 @@ import {
resolveEncryptionKey, resolveEncryptionKey,
resolveScreenProxySecret, resolveScreenProxySecret,
resolveSupervisorToken, resolveSupervisorToken,
resolveTradingBotOrigin,
} from "@rakazo/core"; } from "@rakazo/core";
export { resolveCloudAgentProvider, resolveSandboxProvider } from "@rakazo/adapters"; export { resolveCloudAgentProvider, resolveSandboxProvider } from "@rakazo/adapters";
@ -93,8 +92,6 @@ export interface AppEnv {
updaterToken: string | undefined; updaterToken: string | undefined;
/** Current application image tag; used for compose manual-upgrade command selection. */ /** Current application image tag; used for compose manual-upgrade command selection. */
imageTag: string | undefined; imageTag: string | undefined;
/** Tradingbot origin for the same-origin status proxy. Unset on SaaS builds. */
tradingBotUrl: string | undefined;
} }
export function loadEnv(source: NodeJS.ProcessEnv = process.env): AppEnv { export function loadEnv(source: NodeJS.ProcessEnv = process.env): AppEnv {
@ -182,7 +179,6 @@ export function loadEnv(source: NodeJS.ProcessEnv = process.env): AppEnv {
updaterUrl, updaterUrl,
updaterToken, updaterToken,
imageTag: optional(source.RAKAZO_IMAGE_TAG), imageTag: optional(source.RAKAZO_IMAGE_TAG),
tradingBotUrl: resolveTradingBotOrigin(source.TRADING_BOT_URL),
}; };
} }

View File

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

View File

@ -4,7 +4,7 @@ import { isApprovalAskBlock } from "@rakazo/core";
import type { PrismaClient } from "@rakazo/db"; import type { PrismaClient } from "@rakazo/db";
import { promptSnippet } from "./runs.js"; import { promptSnippet } from "./runs.js";
/** Caps per query; a space rarely holds more waiting runs or unread threads than this. */ /** Caps per query; the user's spaces rarely hold more waiting runs or unread threads than this. */
const INBOX_RUN_LIMIT = 50; const INBOX_RUN_LIMIT = 50;
const INBOX_UNREAD_LIMIT = 50; const INBOX_UNREAD_LIMIT = 50;
const INBOX_PREVIEW_LENGTH = 280; const INBOX_PREVIEW_LENGTH = 280;
@ -12,9 +12,11 @@ const INBOX_PREVIEW_LENGTH = 280;
type AskBlock = Extract<MessageBlock, { kind: "ask" }>; type AskBlock = Extract<MessageBlock, { kind: "ask" }>;
type ComputerBlock = Extract<MessageBlock, { kind: "computer" }>; type ComputerBlock = Extract<MessageBlock, { kind: "computer" }>;
type InboxBot = { id: string; name: string; color: string }; type InboxBot = { id: string; name: string; color: string };
type InboxSpace = NonNullable<InboxItem["space"]>;
export interface InboxRunRow { export interface InboxRunRow {
id: string; id: string;
spaceId: string;
status: string; status: string;
updatedAt: Date; updatedAt: Date;
bot: InboxBot; bot: InboxBot;
@ -33,6 +35,7 @@ export interface InboxMessageRow {
} }
export interface InboxUnreadThreadRow { export interface InboxUnreadThreadRow {
spaceId: string;
createdAt: Date; createdAt: Date;
bot: InboxBot | null; bot: InboxBot | null;
/** The newest bot message, if any. */ /** The newest bot message, if any. */
@ -73,12 +76,14 @@ function previewText(blocks: unknown): string {
} }
/** /**
* Where an ask can be answered from. The main conversation already shows its own asks, and an * Where an ask can be answered from. The open space's main conversation already shows its own
* archived group or an external conversation has no thread target to answer through. * asks (a project's main conversation does not), and an archived group or an external
* conversation has no thread target to answer through.
*/ */
function askTarget(thread: InboxRunRow["thread"]): InboxThreadTarget | null { function askTarget(thread: InboxRunRow["thread"], currentSpace: boolean): InboxThreadTarget | null {
if (thread.groupId) { if (thread.groupId) {
if (!thread.group || thread.group.kind === "main" || thread.group.archivedAt) return null; if (!thread.group || thread.group.archivedAt) return null;
if (thread.group.kind === "main" && currentSpace) return null;
return { groupId: thread.groupId }; return { groupId: thread.groupId };
} }
return thread.botId ? { botId: thread.botId } : null; return thread.botId ? { botId: thread.botId } : null;
@ -95,14 +100,20 @@ function byCreatedAt(left: InboxItem, right: InboxItem): number {
/** /**
* Turns waiting runs, their ask and takeover messages, and unread threads into inbox items: * Turns waiting runs, their ask and takeover messages, and unread threads into inbox items:
* everything that waits on the user first, then unread threads not already covered, each * everything that waits on the user first, then unread threads not already covered, each
* oldest first. * oldest first. Items from another space name it.
*/ */
export function buildInboxItems(input: { export function buildInboxItems(input: {
runs: InboxRunRow[]; runs: InboxRunRow[];
/** Ask and takeover messages of those runs, newest first. */ /** Ask and takeover messages of those runs, newest first. */
messages: InboxMessageRow[]; messages: InboxMessageRow[];
unreadThreads: InboxUnreadThreadRow[]; unreadThreads: InboxUnreadThreadRow[];
/** The user's other spaces by id; rows of any space not listed belong to the current one. */
otherSpaces?: ReadonlyMap<string, InboxSpace>;
}): InboxItem[] { }): InboxItem[] {
const spaceOf = (spaceId: string) => {
const space = input.otherSpaces?.get(spaceId);
return space ? { space } : {};
};
const messagesByRun = new Map<string, InboxMessageRow[]>(); const messagesByRun = new Map<string, InboxMessageRow[]>();
for (const message of input.messages) { for (const message of input.messages) {
if (!message.runId) continue; if (!message.runId) continue;
@ -115,13 +126,14 @@ export function buildInboxItems(input: {
for (const run of input.runs) { for (const run of input.runs) {
const messages = messagesByRun.get(run.id) ?? []; const messages = messagesByRun.get(run.id) ?? [];
if (run.status === "waiting_input") { if (run.status === "waiting_input") {
const target = askTarget(run.thread); const target = askTarget(run.thread, !input.otherSpaces?.has(run.spaceId));
const pending = target ? newest(messages, pendingAsk) : undefined; const pending = target ? newest(messages, pendingAsk) : undefined;
if (!target || !pending) continue; if (!target || !pending) continue;
waiting.push({ waiting.push({
kind: isApprovalAskBlock(pending.block) ? "approval" : "ask", kind: isApprovalAskBlock(pending.block) ? "approval" : "ask",
bot: run.bot, bot: run.bot,
target, target,
...spaceOf(run.spaceId),
runId: run.id, runId: run.id,
messageId: pending.message.id, messageId: pending.message.id,
ask: pending.block, ask: pending.block,
@ -135,6 +147,7 @@ export function buildInboxItems(input: {
kind: "takeover", kind: "takeover",
bot: run.bot, bot: run.bot,
target: { botId: run.bot.id }, target: { botId: run.bot.id },
...spaceOf(run.spaceId),
runId: run.id, runId: run.id,
messageId: request?.message.id ?? null, messageId: request?.message.id ?? null,
text: request?.block.text ?? "", text: request?.block.text ?? "",
@ -154,6 +167,7 @@ export function buildInboxItems(input: {
kind: "unread", kind: "unread",
bot: thread.bot, bot: thread.bot,
target, target,
...spaceOf(thread.spaceId),
messageId: latest?.id ?? null, messageId: latest?.id ?? null,
text: previewText(latest?.blocks), text: previewText(latest?.blocks),
createdAt: (latest?.createdAt ?? thread.createdAt).toISOString(), createdAt: (latest?.createdAt ?? thread.createdAt).toISOString(),
@ -165,13 +179,34 @@ export function buildInboxItems(input: {
const botSelect = { id: true, name: true, color: true } as const; const botSelect = { id: true, name: true, color: true } as const;
/** The current space's inbox: only the actor's own bots, runs and threads. */ /**
* The other spaces whose items the inbox shows: every space the user belongs to, except spaces
* being deleted.
*/
async function otherInboxSpaces(
prisma: PrismaClient,
actor: Actor,
): Promise<Map<string, InboxSpace>> {
const memberships = await prisma.spaceMember.findMany({
where: { userId: actor.userId, spaceId: { not: actor.spaceId }, space: { deletingAt: null } },
select: { space: { select: { id: true, name: true } } },
});
return new Map(memberships.map(({ space }) => [space.id, space]));
}
/**
* The inbox across the user's spaces: only the actor's own bots, runs and threads. Items are
* answered and opened in their own space, where the thread procedures check membership and
* ownership again.
*/
export async function listInbox(prisma: PrismaClient, actor: Actor): Promise<InboxItem[]> { export async function listInbox(prisma: PrismaClient, actor: Actor): Promise<InboxItem[]> {
const otherSpaces = await otherInboxSpaces(prisma, actor);
const spaceIds = [actor.spaceId, ...otherSpaces.keys()];
const ownBot = { userId: actor.userId, archivedAt: null }; const ownBot = { userId: actor.userId, archivedAt: null };
const [runs, unreadThreads] = await Promise.all([ const [runs, unreadThreads] = await Promise.all([
prisma.run.findMany({ prisma.run.findMany({
where: { where: {
spaceId: actor.spaceId, spaceId: { in: spaceIds },
userId: actor.userId, userId: actor.userId,
status: { in: ["waiting_input", "waiting_takeover"] }, status: { in: ["waiting_input", "waiting_takeover"] },
bot: ownBot, bot: ownBot,
@ -179,6 +214,7 @@ export async function listInbox(prisma: PrismaClient, actor: Actor): Promise<Inb
}, },
select: { select: {
id: true, id: true,
spaceId: true,
status: true, status: true,
threadId: true, threadId: true,
updatedAt: true, updatedAt: true,
@ -197,12 +233,13 @@ export async function listInbox(prisma: PrismaClient, actor: Actor): Promise<Inb
// Only specialists' own threads; group threads, the main conversation included, stay out. // Only specialists' own threads; group threads, the main conversation included, stay out.
prisma.thread.findMany({ prisma.thread.findMany({
where: { where: {
spaceId: actor.spaceId, spaceId: { in: spaceIds },
userId: actor.userId, userId: actor.userId,
unread: true, unread: true,
bot: { ...ownBot, kind: "specialist" }, bot: { ...ownBot, kind: "specialist" },
}, },
select: { select: {
spaceId: true,
createdAt: true, createdAt: true,
bot: { select: botSelect }, bot: { select: botSelect },
messages: { messages: {
@ -232,5 +269,5 @@ export async function listInbox(prisma: PrismaClient, actor: Actor): Promise<Inb
orderBy: { seq: "desc" }, orderBy: { seq: "desc" },
select: { id: true, runId: true, blocks: true, createdAt: true }, select: { id: true, runId: true, blocks: true, createdAt: true },
}); });
return buildInboxItems({ runs, messages, unreadThreads }); return buildInboxItems({ runs, messages, unreadThreads, otherSpaces });
} }

View File

@ -43,13 +43,20 @@ export async function assertNotMainConversation(
if (group?.kind === "main") throw new ORPCError("BAD_REQUEST", { message }); if (group?.kind === "main") throw new ORPCError("BAD_REQUEST", { message });
} }
/** The main conversation carries the main bot's name. */ /** A main conversation carries its main bot's name. */
export async function renameMainConversation(prisma: PrismaClient, userId: string, name: string) { export async function renameMainConversation(
await prisma.chatGroup.updateMany({ where: { userId, kind: "main" }, data: { name } }); prisma: PrismaClient,
main: { userId: string; spaceId: string },
name: string,
) {
await prisma.chatGroup.updateMany({
where: { userId: main.userId, spaceId: main.spaceId, kind: "main" },
data: { name },
});
} }
/** /**
* Same-space bots the user addresses in the main conversation, by `@Name` or a mention chip, * Same-space bots the user addresses in a main conversation, by `@Name` or a mention chip,
* join it first, so the member mention rules route the message to them. * join it first, so the member mention rules route the message to them.
*/ */
export async function joinAddressedBots( export async function joinAddressedBots(
@ -71,5 +78,9 @@ export async function joinAddressedBots(
.map((bot) => bot.id); .map((bot) => bot.id);
if (addressed.length === 0) return; if (addressed.length === 0) return;
// Subscribers pick up the group.updated event with the send that follows. // Subscribers pick up the group.updated event with the send that follows.
await joinMainConversation(prisma, { userId: actor.userId, botIds: addressed }); await joinMainConversation(prisma, {
userId: actor.userId,
spaceId: actor.spaceId,
botIds: addressed,
});
} }

View File

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

View File

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

View File

@ -1,64 +1,192 @@
import { mkdir, mkdtemp, writeFile } from "node:fs/promises"; import { mkdir, mkdtemp, realpath, rm, symlink, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os"; import { tmpdir } from "node:os";
import { join } from "node:path"; import path from "node:path";
import { afterEach, describe, expect, it, vi } from "vitest"; import type { PrismaClient } from "@rakazo/db";
import { getScanStatus, startOnboardingScan } from "./onboarding-scan.js"; import { afterAll, describe, expect, it, vi } from "vitest";
import {
applyOnboardingScan,
discoverOnboardingScan,
onboardingScanFolder,
onboardingScanStatus,
startOnboardingScan,
} from "./onboarding-scan.js";
const owner = { userId: "user-owner", isDeploymentOwner: true }; const owner = { userId: "user-owner", spaceId: "space-owner", isDeploymentOwner: true };
const member = { userId: "user-member", isDeploymentOwner: false }; const member = { userId: "user-member", spaceId: "space-member", isDeploymentOwner: false };
const roots: string[] = [];
async function scanHomeWithOneFile(): Promise<string> { /** Fails the test if the scan writes anything before its checks pass. */
const home = await mkdtemp(join(tmpdir(), "evoo-scan-")); const untouchedPrisma = new Proxy(
await mkdir(join(home, "Projects")); {},
await writeFile(join(home, "Projects", "notes.ts"), "export const x = 1;\n"); {
return home; get() {
throw new Error("the database must not be touched");
},
},
) as PrismaClient;
async function tempDir(): Promise<string> {
const dir = await realpath(await mkdtemp(path.join(tmpdir(), "evoo-scan-")));
roots.push(dir);
return dir;
}
async function files(root: string, entries: string[]): Promise<void> {
for (const entry of entries) {
const file = path.join(root, entry);
await mkdir(path.dirname(file), { recursive: true });
await writeFile(file, "x\n");
}
}
async function project(root: string, relative: string, entries: string[]): Promise<void> {
await mkdir(path.join(root, relative, ".git"), { recursive: true });
await files(path.join(root, relative), entries);
} }
async function completed(scanId: string, userId: string) { async function completed(scanId: string, userId: string) {
return await vi.waitFor( return await vi.waitFor(
async () => { () => {
const status = await getScanStatus(scanId, userId); const status = onboardingScanStatus(scanId, userId);
if (status.status !== "complete" && status.status !== "failed") throw new Error("pending"); if (status.status === "scanning") throw new Error("pending");
return status; return status;
}, },
{ timeout: 10_000, interval: 50 }, { timeout: 10_000, interval: 20 },
); );
} }
afterAll(async () => {
await Promise.all(roots.map((root) => rm(root, { recursive: true, force: true })));
});
describe("onboarding scan", () => { describe("onboarding scan", () => {
afterEach(() => { it("lists git projects with their languages and tools and never follows a symlink", async () => {
vi.unstubAllEnvs(); const home = await tempDir();
const outside = await tempDir();
// A home folder tracked by git is not itself a project.
await mkdir(path.join(home, ".git"));
await project(home, "code/atlas", [
"src/a.ts",
"src/b.ts",
"src/c.ts",
"src/d.tsx",
"scripts/build.js",
"Dockerfile",
".github/workflows/ci.yml",
"infra/main.tf",
"src/odd.constructor",
"node_modules/dep/index.js",
"node_modules/dep/more.js",
]);
await project(home, "code/garden", ["main.py"]);
await project(home, "deep/a/b/edge", ["tool.py"]);
await project(home, "deep/a/b/c/too-deep", ["main.go"]);
await project(home, ".hidden/secret", ["main.go"]);
await project(home, "notes/node_modules/vendored", ["main.go"]);
await files(home, ["notes/todo.md"]);
await project(outside, "escaped", ["main.rs"]);
await symlink(path.join(outside, "escaped"), path.join(home, "linked"));
await symlink(outside, path.join(home, "code/atlas/src/outside"));
const findings = await discoverOnboardingScan(home);
expect(findings.projects).toEqual([
{
id: "project-0",
name: "atlas",
path: "code/atlas",
languages: ["TypeScript", "JavaScript"],
tools: ["Docker", "GitHub Actions", "Terraform"],
},
{ id: "project-1", name: "garden", path: "code/garden", languages: ["Python"], tools: [] },
{ id: "project-2", name: "edge", path: "deep/a/b/edge", languages: ["Python"], tools: [] },
]);
expect(findings.facts).toEqual([
{ id: "language:Python", label: "Python", category: "work", value: "Codes in Python" },
{
id: "language:JavaScript",
label: "JavaScript",
category: "work",
value: "Codes in JavaScript",
},
{
id: "language:TypeScript",
label: "TypeScript",
category: "work",
value: "Codes in TypeScript",
},
{ id: "tool:Docker", label: "Docker", category: "tools", value: "Uses Docker" },
{
id: "tool:GitHub Actions",
label: "GitHub Actions",
category: "tools",
value: "Uses GitHub Actions",
},
{ id: "tool:Terraform", label: "Terraform", category: "tools", value: "Uses Terraform" },
]);
}); });
it("hides a scan from every account except the one that started it", async () => { it("offers a scan folder only to the deployment owner and only when it exists", async () => {
const { scanId } = await startOnboardingScan({ actor: member }, {}); const home = await tempDir();
await expect(getScanStatus(scanId, owner.userId)).rejects.toThrow(/Scan not found/); await files(home, ["file.txt"]);
await expect(getScanStatus(scanId, member.userId)).resolves.toMatchObject({ await expect(onboardingScanFolder(member, home)).resolves.toBeNull();
status: expect.any(String), await expect(onboardingScanFolder(owner, undefined)).resolves.toBeNull();
}); await expect(onboardingScanFolder(owner, path.join(home, "missing"))).resolves.toBeNull();
await expect(getScanStatus("scan_missing", member.userId)).rejects.toThrow(/Scan not found/); await expect(onboardingScanFolder(owner, path.join(home, "file.txt"))).resolves.toBeNull();
await expect(onboardingScanFolder(owner, home)).resolves.toBe(home);
await expect(startOnboardingScan(member, home)).rejects.toMatchObject({ code: "FORBIDDEN" });
}); });
it("never reads the host without a configured scan home", async () => { it("keeps a scan with the account that started it and returns only relative paths", async () => {
const { scanId } = await startOnboardingScan({ actor: owner }, {}); const home = await tempDir();
await project(home, "code/atlas", ["index.ts"]);
const { scanId } = await startOnboardingScan(owner, home);
const status = await completed(scanId, owner.userId); const status = await completed(scanId, owner.userId);
expect(status.status).toBe("complete"); expect(status).toEqual({
expect(status.result).toMatchObject({ localRepos: [], fileSummary: { totalFiles: 0 } }); status: "complete",
projects: [{ id: "project-0", name: "atlas", path: "code/atlas" }],
facts: [{ id: "language:TypeScript", label: "TypeScript" }],
});
expect(JSON.stringify(status)).not.toContain(home);
expect(() => onboardingScanStatus(scanId, member.userId)).toThrow(
expect.objectContaining({ code: "NOT_FOUND" }),
);
expect(() => onboardingScanStatus("missing-scan", owner.userId)).toThrow(
expect.objectContaining({ code: "NOT_FOUND" }),
);
await expect(
applyOnboardingScan(untouchedPrisma, member, home, {
scanId,
projectIds: ["project-0"],
factIds: [],
}),
).rejects.toMatchObject({ code: "NOT_FOUND" });
}); });
it("lets only the deployment owner discover the configured scan home", async () => { it("checks ownership, the folder and every chosen project again before writing", async () => {
// The shared test setup pins the emulated runtime, which also keeps the scan simulated. const home = await tempDir();
vi.stubEnv("AGENT_RUNTIME", ""); const outside = await tempDir();
vi.stubEnv("SANDBOX_PROVIDER", ""); await project(home, "code/atlas", ["index.ts"]);
const scanHome = await scanHomeWithOneFile(); await project(home, "code/garden", ["main.py"]);
await project(outside, "elsewhere", ["main.rs"]);
const { scanId } = await startOnboardingScan(owner, home);
await completed(scanId, owner.userId);
const choice = { scanId, projectIds: ["project-1"], factIds: ["language:Python"] };
const asMember = await startOnboardingScan({ actor: member, scanHome }, {}); await expect(
const memberStatus = await completed(asMember.scanId, member.userId); applyOnboardingScan(untouchedPrisma, { ...owner, isDeploymentOwner: false }, home, choice),
expect(memberStatus.result).toMatchObject({ fileSummary: { totalFiles: 0 } }); ).rejects.toMatchObject({ code: "FORBIDDEN" });
await expect(
applyOnboardingScan(untouchedPrisma, owner, outside, choice),
).rejects.toMatchObject({ code: "FORBIDDEN" });
const asOwner = await startOnboardingScan({ actor: owner, scanHome }, {}); // The chosen project became a symlink out of the scan folder after the scan.
const ownerStatus = await completed(asOwner.scanId, owner.userId); await rm(path.join(home, "code/garden"), { recursive: true });
expect(ownerStatus.status).toBe("complete"); await symlink(path.join(outside, "elsewhere"), path.join(home, "code/garden"));
expect(ownerStatus.result).toMatchObject({ fileSummary: { totalFiles: 1 } }); await expect(applyOnboardingScan(untouchedPrisma, owner, home, choice)).rejects.toMatchObject({
code: "BAD_REQUEST",
message: "That folder does not exist or is not readable.",
});
}); });
}); });

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

@ -11,6 +11,7 @@ import type {
} from "@rakazo/adapter-kit"; } from "@rakazo/adapter-kit";
import { import {
computerControlExpireJobKey, computerControlExpireJobKey,
MemoryDocumentTooLargeError,
messagingDeliverJob, messagingDeliverJob,
routineJobKey, routineJobKey,
routineWakeupJob, routineWakeupJob,
@ -27,6 +28,7 @@ import type {
MemoryProviderResolver, MemoryProviderResolver,
PiOAuthLogins, PiOAuthLogins,
RemoteConnectorDependencies, RemoteConnectorDependencies,
VapidKeys,
} from "@rakazo/adapters"; } from "@rakazo/adapters";
import { import {
acquireComputerExecutionLease, acquireComputerExecutionLease,
@ -43,7 +45,6 @@ import {
computerSupportsUpdate, computerSupportsUpdate,
computerUpdateView, computerUpdateView,
createVoiceProvider, createVoiceProvider,
deletePushToken,
deploymentAutoReviewDefault, deploymentAutoReviewDefault,
destroyBot, destroyBot,
displayBotWorkspacePath, displayBotWorkspacePath,
@ -52,6 +53,7 @@ import {
filterTeamWorkspaceEntries, filterTeamWorkspaceEntries,
hasActiveComputerControl, hasActiveComputerControl,
isAutoReviewCheckerConfigured, isAutoReviewCheckerConfigured,
isBlockedHostname,
isComputerScreenUnavailable, isComputerScreenUnavailable,
isProjectWorkspacePath, isProjectWorkspacePath,
isSandboxGoneError, isSandboxGoneError,
@ -74,7 +76,6 @@ import {
resolveAutoReviewChecker, resolveAutoReviewChecker,
resolveBotWorkspacePath, resolveBotWorkspacePath,
sanitizeComposioError, sanitizeComposioError,
savePushToken,
scheduleComputerControlExpiry, scheduleComputerControlExpiry,
scheduleComputerSleep, scheduleComputerSleep,
screenLeaseIdForRun, screenLeaseIdForRun,
@ -88,9 +89,18 @@ import {
verifyMcpInstall, verifyMcpInstall,
} from "@rakazo/adapters"; } from "@rakazo/adapters";
import type { Auth } from "@rakazo/auth"; import type { Auth } from "@rakazo/auth";
import type { Actor, ComputerStatus, McpServer, Me, SpaceNavigation } from "@rakazo/contracts"; import type {
Actor,
ComputerStatus,
McpServer,
Me,
SpaceBot,
SpaceGroup,
SpaceNavigation,
} from "@rakazo/contracts";
import { import {
appContract, appContract,
CoachRoutineKeySchema,
IntegrationProviderIdSchema, IntegrationProviderIdSchema,
OPENAI_COMPATIBLE_PROVIDER_ID, OPENAI_COMPATIBLE_PROVIDER_ID,
} from "@rakazo/contracts"; } from "@rakazo/contracts";
@ -123,6 +133,7 @@ import {
findModelCredential, findModelCredential,
findSpaceModelTier, findSpaceModelTier,
formatMessagingLinkCode, formatMessagingLinkCode,
getNotificationSettings,
InvalidProjectWorkspacePathError, InvalidProjectWorkspacePathError,
InvalidSpaceNameError, InvalidSpaceNameError,
IsolationError, IsolationError,
@ -131,19 +142,25 @@ import {
listSpaceUserBotIds, listSpaceUserBotIds,
newestModelCredentialOrder, newestModelCredentialOrder,
newestVoiceCredentialOrder, newestVoiceCredentialOrder,
ownMainAgentsBlockingSpaceDeletion,
Prisma, Prisma,
parseComputerMode, parseComputerMode,
projectWorkspaceDto, projectWorkspaceDto,
releaseSpaceDeletionClaim, releaseSpaceDeletionClaim,
removePushSubscription,
removePushSubscriptionsOfKind,
renewSpaceDeletionClaim, renewSpaceDeletionClaim,
requireMembership, requireMembership,
resolveProjectWorkspaceDirectory,
SPACE_DELETION_CLAIM_TIMEOUT_MS, SPACE_DELETION_CLAIM_TIMEOUT_MS,
SpaceDeletionInProgressError, SpaceDeletionInProgressError,
SpaceLimitError, SpaceLimitError,
SpaceNotEmptyError, SpaceNotEmptyError,
SpaceNotFoundError, SpaceNotFoundError,
savePushSubscription,
selectSpaceModelPreference, selectSpaceModelPreference,
selectSpaceVoicePreference, selectSpaceVoicePreference,
updateNotificationSettings,
wireProjectWorkspace, wireProjectWorkspace,
} from "@rakazo/db"; } from "@rakazo/db";
import { getLogger } from "@rakazo/logging"; import { getLogger } from "@rakazo/logging";
@ -152,11 +169,13 @@ import { createAgentSkillsService } from "./agent-skills.js";
import { aiConsentStatus, allowAiConsent } from "./ai-consent.js"; import { aiConsentStatus, allowAiConsent } from "./ai-consent.js";
import { createOwnedArtifact, getOwnedArtifact, getSpaceArtifact } from "./artifacts.js"; import { createOwnedArtifact, getOwnedArtifact, getSpaceArtifact } from "./artifacts.js";
import { botProfileLabelsChanged, commitBotUpdate } from "./bot-update.js"; import { botProfileLabelsChanged, commitBotUpdate } from "./bot-update.js";
import { createCoachService } from "./coach.js";
import { import {
executionBlocksUserTakeover, executionBlocksUserTakeover,
resolveBusyBotName, resolveBusyBotName,
toComputerStatus, toComputerStatus,
} from "./computer-status.js"; } from "./computer-status.js";
import { recordConnectedApp } from "./connected-app-facts.js";
import { listInbox } from "./inbox.js"; import { listInbox } from "./inbox.js";
import { searchIntegrationCatalog } from "./integration-catalog.js"; import { searchIntegrationCatalog } from "./integration-catalog.js";
import { import {
@ -180,7 +199,12 @@ import {
promptFocus, promptFocus,
startOnboarding, startOnboarding,
} from "./onboarding.js"; } from "./onboarding.js";
import { getScanStatus, startOnboardingScan } from "./onboarding-scan.js"; import {
applyOnboardingScan,
onboardingScanFolder,
onboardingScanStatus,
startOnboardingScan,
} from "./onboarding-scan.js";
import { listSpaceRuns } from "./runs.js"; import { listSpaceRuns } from "./runs.js";
import { addScreenProxyCapability } from "./screen-proxy.js"; import { addScreenProxyCapability } from "./screen-proxy.js";
import { querySpaceSearch } from "./search.js"; import { querySpaceSearch } from "./search.js";
@ -285,6 +309,11 @@ async function reconcilePendingConnections(
: []), : []),
]; ];
if (updates.length > 0) await prisma.$transaction(updates); if (updates.length > 0) await prisma.$transaction(updates);
const connected = new Set(sync.connectIds);
for (const row of rows) {
if (row.status !== "pending" || !connected.has(row.id)) continue;
await recordConnectedApp(prisma, { userId: owner.userId, connectorId, provider: row.provider });
}
} }
/** Serialize begin/revoke for one user+provider so slug-wide remote deletes cannot race a new connect. */ /** Serialize begin/revoke for one user+provider so slug-wide remote deletes cannot race a new connect. */
@ -457,6 +486,8 @@ export interface RouterDeps {
secrets: EncryptedSecretStore; secrets: EncryptedSecretStore;
oauthLogins: PiOAuthLogins; oauthLogins: PiOAuthLogins;
integrationSettings?: IntegrationProviderSettings; integrationSettings?: IntegrationProviderSettings;
/** The deployment's web push key pair; without it browsers cannot subscribe. */
vapid?: Pick<VapidKeys, "publicKey">;
composio?: ComposioProvider; composio?: ComposioProvider;
mcpOAuth?: McpOAuthBroker; mcpOAuth?: McpOAuthBroker;
connectors: ConnectorRegistry; connectors: ConnectorRegistry;
@ -490,6 +521,26 @@ export interface RouterDeps {
* keep the deletion claim renewed forever and block stale-claim recovery. */ * keep the deletion claim renewed forever and block stale-claim recovery. */
const SPACE_TEARDOWN_TIMEOUT_MS = 120_000; const SPACE_TEARDOWN_TIMEOUT_MS = 120_000;
async function removeGroupWithArtifacts(
deps: RouterDeps,
groupRepos: ReturnType<typeof createGroupRepos>,
actor: Actor,
groupId: string,
) {
const removed = await groupRepos.removeGroup(actor, groupId);
const cleanup = await Promise.allSettled(
removed.artifactStorageKeys.map((storageKey) =>
deps.artifacts.remove(
storageKey,
computerContext(actor, removed.contextBotId, `group-remove:${groupId}`),
),
),
);
for (const result of cleanup) {
if (result.status === "rejected") getLogger().error("group artifact cleanup", result.reason);
}
}
function spaceTeardownTimeoutMs(): number { function spaceTeardownTimeoutMs(): number {
const override = Number(process.env.SPACE_TEARDOWN_TIMEOUT_MS ?? ""); const override = Number(process.env.SPACE_TEARDOWN_TIMEOUT_MS ?? "");
return Number.isFinite(override) && override > 0 ? override : SPACE_TEARDOWN_TIMEOUT_MS; return Number.isFinite(override) && override > 0 ? override : SPACE_TEARDOWN_TIMEOUT_MS;
@ -504,9 +555,18 @@ function mapSpaceLifecycleError(error: unknown): unknown {
} }
export function createRouter(deps: RouterDeps) { export function createRouter(deps: RouterDeps) {
const os = implement(appContract).$context<{ actor: Actor | null; signal?: AbortSignal }>(); const os = implement(appContract).$context<{
actor: Actor | null;
signal?: AbortSignal;
userAgent?: string;
}>();
const repos = createRepos(deps.prisma); const repos = createRepos(deps.prisma);
const onboardingDeps = { prisma: deps.prisma, events: deps.events, connectors: deps.connectors }; const onboardingDeps = {
prisma: deps.prisma,
events: deps.events,
connectors: deps.connectors,
jobs: deps.jobs,
};
const mcpOAuth = deps.mcpOAuth ?? new McpOAuthBroker(deps.prisma, deps.secrets); const mcpOAuth = deps.mcpOAuth ?? new McpOAuthBroker(deps.prisma, deps.secrets);
const groupRepos = createGroupRepos(deps.prisma); const groupRepos = createGroupRepos(deps.prisma);
const projectGoalRepos = createProjectGoalRepos(deps.prisma); const projectGoalRepos = createProjectGoalRepos(deps.prisma);
@ -519,6 +579,7 @@ export function createRouter(deps: RouterDeps) {
dataDir: deps.dataDir, dataDir: deps.dataDir,
}); });
const agentSkills = createAgentSkillsService(deps.prisma); const agentSkills = createAgentSkillsService(deps.prisma);
const coach = createCoachService(deps);
const authed = os.use(async ({ context, next }) => { const authed = os.use(async ({ context, next }) => {
if (!context.actor) throw new ORPCError("UNAUTHORIZED"); if (!context.actor) throw new ORPCError("UNAUTHORIZED");
@ -558,17 +619,78 @@ export function createRouter(deps: RouterDeps) {
profile: { profile: {
get: authed.profile.get.handler(({ context }) => getProfile(deps.prisma, context.actor)), get: authed.profile.get.handler(({ context }) => getProfile(deps.prisma, context.actor)),
update: authed.profile.update.handler(({ context, input }) => update: authed.profile.update.handler(({ context, input }) =>
updateProfile(deps.prisma, context.actor, input), updateProfile(deps, context.actor, input),
), ),
forget: authed.profile.forget.handler(({ context, input }) => forget: authed.profile.forget.handler(({ context, input }) =>
forgetProfile(deps.prisma, context.actor, input), forgetProfile(deps, context.actor, input),
), ),
}, },
coach: {
today: authed.coach.today.handler(({ context }) => coach.today(context.actor)),
goals: {
list: authed.coach.goals.list.handler(({ context, input }) =>
coach.listGoals(context.actor, input),
),
create: authed.coach.goals.create.handler(({ context, input }) =>
coach.createGoal(context.actor, input),
),
update: authed.coach.goals.update.handler(({ context, input }) =>
coach.updateGoal(context.actor, input),
),
remove: authed.coach.goals.remove.handler(({ context, input }) =>
coach.removeGoal(context.actor, input),
),
},
habits: {
list: authed.coach.habits.list.handler(({ context, input }) =>
coach.listHabits(context.actor, input),
),
create: authed.coach.habits.create.handler(({ context, input }) =>
coach.createHabit(context.actor, input),
),
update: authed.coach.habits.update.handler(({ context, input }) =>
coach.updateHabit(context.actor, input),
),
remove: authed.coach.habits.remove.handler(({ context, input }) =>
coach.removeHabit(context.actor, input),
),
},
checkIns: {
log: authed.coach.checkIns.log.handler(({ context, input }) =>
coach.logCheckIn(context.actor, input),
),
list: authed.coach.checkIns.list.handler(({ context, input }) =>
coach.listCheckIns(context.actor, input),
),
},
rhythm: {
get: authed.coach.rhythm.get.handler(({ context }) => coach.getRhythm(context.actor)),
set: authed.coach.rhythm.set.handler(({ context, input }) =>
coach.setRhythm(context.actor, input),
),
},
},
spaces: { spaces: {
list: authed.spaces.list.handler(async ({ context }) => list: authed.spaces.list.handler(async ({ context }) =>
spaceNavigationDto(deps, context.actor, repos, groupRepos), spaceNavigationDto(deps, context.actor, repos, groupRepos),
), ),
create: authed.spaces.create.handler(async ({ context, input }) => { create: authed.spaces.create.handler(async ({ context, input }) => {
// A linked folder lives on the host, and the host is the deployment owner's machine.
if (input.workspacePath && !context.actor.isDeploymentOwner) {
throw new ORPCError("FORBIDDEN");
}
// Check the folder before the space exists, so a bad path leaves no space behind.
let workspacePath: string | null = null;
if (input.workspacePath) {
try {
workspacePath = await resolveProjectWorkspaceDirectory(input.workspacePath);
} catch (error) {
if (error instanceof InvalidProjectWorkspacePathError) {
throw new ORPCError("BAD_REQUEST", { message: error.message });
}
throw error;
}
}
let space: { id: string; name: string }; let space: { id: string; name: string };
try { try {
space = await createSpaceForMember(deps.prisma, { space = await createSpaceForMember(deps.prisma, {
@ -582,20 +704,16 @@ export function createRouter(deps: RouterDeps) {
} }
throw error; throw error;
} }
let workspace = null; const workspace = workspacePath
if (input.workspacePath) { ? await wireProjectWorkspace(deps.prisma, { spaceId: space.id, workspacePath })
try { : null;
workspace = await wireProjectWorkspace(deps.prisma, { // A project starts as a conversation with its own main agent.
spaceId: space.id, await ensureMainConversation(deps.prisma, context.actor.userId, space.id);
workspacePath: input.workspacePath, const spaceActor = await requireMembership(deps.prisma, context.actor.userId, space.id);
}); const [bots, groups] = await Promise.all([
} catch (error) { repos.listBots(spaceActor),
if (error instanceof InvalidProjectWorkspacePathError) { groupRepos.listGroups(spaceActor),
throw new ORPCError("BAD_REQUEST", { message: error.message }); ]);
}
throw error;
}
}
return { return {
id: space.id, id: space.id,
name: space.name, name: space.name,
@ -603,12 +721,26 @@ export function createRouter(deps: RouterDeps) {
hasContent: false, hasContent: false,
canDelete: true, canDelete: true,
workspace, workspace,
bots: [], bots: bots.map(spaceBotDto),
groups: [], groups: groups.map(spaceGroupDto),
externalConversations: [], externalConversations: [],
botSections: [], botSections: [],
}; };
}), }),
ensureMain: authed.spaces.ensureMain.handler(async ({ context, input }) => {
const conversation = await ensureMainConversation(
deps.prisma,
context.actor.userId,
input.spaceId,
).catch((error: unknown) => {
throw mapSpaceLifecycleError(error);
});
return {
botId: conversation.leadBotId,
groupId: conversation.groupId,
spaceId: conversation.spaceId,
};
}),
remove: authed.spaces.remove.handler(async ({ context, input }) => { remove: authed.spaces.remove.handler(async ({ context, input }) => {
let claimId: string | null = null; let claimId: string | null = null;
let claimActive = false; let claimActive = false;
@ -621,6 +753,41 @@ export function createRouter(deps: RouterDeps) {
spaceId: input.spaceId, spaceId: input.spaceId,
}; };
try { try {
// The project's own main agent goes with the project; anything else must be
// cleared first.
const ownMain = await ownMainAgentsBlockingSpaceDeletion(deps.prisma, deleteInput);
if (ownMain.groupIds.length > 0 || ownMain.botIds.length > 0) {
const spaceActor = await requireMembership(
deps.prisma,
context.actor.userId,
input.spaceId,
);
for (const groupId of ownMain.groupIds) {
await removeGroupWithArtifacts(deps, groupRepos, spaceActor, groupId);
}
for (const botId of ownMain.botIds) {
const bot = await repos.getBot(spaceActor, botId, { includeArchived: true });
await destroyBot(
{
prisma: deps.prisma,
sandbox: deps.sandbox,
home: deps.home,
jobs: deps.jobs,
artifacts: deps.artifacts,
dataDir: deps.dataDir,
},
bot,
{
operationId: "spaces.remove",
traceId: "spaces.remove",
spaceId: input.spaceId,
userId: context.actor.userId,
signal: new AbortController().signal,
},
{ deleteMemories: true },
);
}
}
// Claim emptiness before external teardown. Bot and group creation // Claim emptiness before external teardown. Bot and group creation
// take the same lifecycle lock and reject the Space until deletion // take the same lifecycle lock and reject the Space until deletion
// finishes or this claim is released. // finishes or this claim is released.
@ -1219,7 +1386,11 @@ export function createRouter(deps: RouterDeps) {
}, },
}); });
if (existing.kind === "main" && input.name !== undefined && input.name !== existing.name) { if (existing.kind === "main" && input.name !== undefined && input.name !== existing.name) {
await renameMainConversation(deps.prisma, context.actor.userId, input.name); await renameMainConversation(
deps.prisma,
{ userId: context.actor.userId, spaceId: existing.spaceId },
input.name,
);
} }
const bots = await repos.listBots(context.actor); const bots = await repos.listBots(context.actor);
const bot = bots.find((b) => b.id === input.botId); const bot = bots.find((b) => b.id === input.botId);
@ -1507,19 +1678,7 @@ export function createRouter(deps: RouterDeps) {
input.groupId, input.groupId,
"The main conversation can't be deleted.", "The main conversation can't be deleted.",
); );
const removed = await groupRepos.removeGroup(context.actor, input.groupId); await removeGroupWithArtifacts(deps, groupRepos, context.actor, input.groupId);
const cleanup = await Promise.allSettled(
removed.artifactStorageKeys.map((storageKey) =>
deps.artifacts.remove(
storageKey,
computerContext(context.actor, removed.contextBotId, `group-remove:${input.groupId}`),
),
),
);
for (const result of cleanup) {
if (result.status === "rejected")
getLogger().error("group artifact cleanup", result.reason);
}
return { ok: true as const }; return { ok: true as const };
}), }),
}, },
@ -2230,10 +2389,7 @@ export function createRouter(deps: RouterDeps) {
} }
} }
if (isProjectWorkspacePath(storedPath)) { if (isProjectWorkspacePath(storedPath)) {
const workspaceRoot = await requireProjectWorkspaceRoot( const workspaceRoot = await requireProjectWorkspaceRoot(deps.prisma, context.actor);
deps.prisma,
context.actor.spaceId,
);
const entries = await listProjectWorkspaceFiles(workspaceRoot, storedPath); const entries = await listProjectWorkspaceFiles(workspaceRoot, storedPath);
return entries.map((entry) => ({ return entries.map((entry) => ({
...entry, ...entry,
@ -2281,10 +2437,7 @@ export function createRouter(deps: RouterDeps) {
} }
} }
if (isProjectWorkspacePath(storedPath)) { if (isProjectWorkspacePath(storedPath)) {
const workspaceRoot = await requireProjectWorkspaceRoot( const workspaceRoot = await requireProjectWorkspaceRoot(deps.prisma, context.actor);
deps.prisma,
context.actor.spaceId,
);
const bytes = await readProjectWorkspaceFile(workspaceRoot, storedPath, { const bytes = await readProjectWorkspaceFile(workspaceRoot, storedPath, {
maxBytes: MAX_COMPUTER_TEXT_FILE_BYTES, maxBytes: MAX_COMPUTER_TEXT_FILE_BYTES,
}); });
@ -2427,7 +2580,8 @@ export function createRouter(deps: RouterDeps) {
}, },
}); });
if (!doc) throw new IsolationError(); if (!doc) throw new IsolationError();
const updated = await deps.memory.commit( const updated = await deps.memory
.commit(
{ {
scope: doc.scope as "bot" | "user", scope: doc.scope as "bot" | "user",
botId: doc.botId ?? undefined, botId: doc.botId ?? undefined,
@ -2441,7 +2595,13 @@ export function createRouter(deps: RouterDeps) {
userId: context.actor.userId, userId: context.actor.userId,
signal: new AbortController().signal, signal: new AbortController().signal,
}, },
); )
.catch((error: unknown) => {
if (error instanceof MemoryDocumentTooLargeError) {
throw new ORPCError("BAD_REQUEST", { message: error.message });
}
throw error;
});
return { return {
id: updated.id, id: updated.id,
scope: doc.scope as "bot" | "user", scope: doc.scope as "bot" | "user",
@ -3417,7 +3577,7 @@ export function createRouter(deps: RouterDeps) {
return { ok: true as const }; return { ok: true as const };
}), }),
choose: authed.onboarding.choose.handler(async ({ context, input }) => { choose: authed.onboarding.choose.handler(async ({ context, input }) => {
await chooseFocus(onboardingDeps, context.actor, input.botId, input.optionId); await chooseFocus(onboardingDeps, context.actor, input.botId, input);
return { ok: true as const }; return { ok: true as const };
}), }),
dismissFocus: authed.onboarding.dismissFocus.handler(async ({ context, input }) => { dismissFocus: authed.onboarding.dismissFocus.handler(async ({ context, input }) => {
@ -3434,16 +3594,18 @@ export function createRouter(deps: RouterDeps) {
); );
return { ok: true as const }; return { ok: true as const };
}), }),
scan: authed.onboarding.scan.handler(async ({ context, input }) => { scanTarget: authed.onboarding.scanTarget.handler(async ({ context }) => ({
const { scanId } = await startOnboardingScan( folder: await onboardingScanFolder(context.actor, deps.env.onboardingScanHome),
{ actor: context.actor, botId: input.botId, scanHome: deps.env.onboardingScanHome }, })),
deps.prisma, scan: authed.onboarding.scan.handler(({ context }) =>
); startOnboardingScan(context.actor, deps.env.onboardingScanHome),
return { scanId }; ),
}), scanStatus: authed.onboarding.scanStatus.handler(async ({ context, input }) =>
scanStatus: authed.onboarding.scanStatus.handler(async ({ context, input }) => { onboardingScanStatus(input.scanId, context.actor.userId),
return await getScanStatus(input.scanId, context.actor.userId); ),
}), applyScan: authed.onboarding.applyScan.handler(({ context, input }) =>
applyOnboardingScan(deps.prisma, context.actor, deps.env.onboardingScanHome, input),
),
}, },
integrationSetup: { integrationSetup: {
get: authed.integrationSetup.get.handler(async ({ context }) => { get: authed.integrationSetup.get.handler(async ({ context }) => {
@ -3692,6 +3854,13 @@ export function createRouter(deps: RouterDeps) {
} }
throw new IsolationError(); throw new IsolationError();
} }
if (!auth.authorizationUrl) {
await recordConnectedApp(deps.prisma, {
userId: context.actor.userId,
connectorId: input.connectorId,
provider: input.provider,
});
}
return { connectionId: row.id, authorizationUrl: auth.authorizationUrl }; return { connectionId: row.id, authorizationUrl: auth.authorizationUrl };
} catch (error) { } catch (error) {
if (error instanceof IsolationError) throw error; if (error instanceof IsolationError) throw error;
@ -3997,6 +4166,13 @@ export function createRouter(deps: RouterDeps) {
}, },
{ timeout: 60_000 }, { timeout: 60_000 },
); );
if (row.status === "connected") {
await recordConnectedApp(deps.prisma, {
userId: context.actor.userId,
connectorId: row.connectorId,
provider: row.provider,
});
}
} }
return { return {
id: row.id, id: row.id,
@ -4751,13 +4927,53 @@ export function createRouter(deps: RouterDeps) {
}, },
notifications: { notifications: {
registerPush: authed.notifications.registerPush.handler(async ({ context, input }) => { registerPush: authed.notifications.registerPush.handler(async ({ context, input }) => {
await savePushToken(deps.dataDir, context.actor.userId, input.token); await savePushSubscription(deps.prisma, context.actor.userId, {
kind: "expo",
endpoint: input.token,
userAgent: context.userAgent,
});
return { ok: true as const }; return { ok: true as const };
}), }),
// The mobile app signs out without naming its token, so all of its tokens go.
unregisterPush: authed.notifications.unregisterPush.handler(async ({ context }) => { unregisterPush: authed.notifications.unregisterPush.handler(async ({ context }) => {
await deletePushToken(deps.dataDir, context.actor.userId); await removePushSubscriptionsOfKind(deps.prisma, context.actor.userId, "expo");
return { ok: true as const }; return { ok: true as const };
}), }),
settings: {
get: authed.notifications.settings.get.handler(({ context }) =>
getNotificationSettings(deps.prisma, context.actor.userId),
),
update: authed.notifications.settings.update.handler(({ context, input }) =>
updateNotificationSettings(deps.prisma, context.actor.userId, input),
),
},
webPush: {
config: authed.notifications.webPush.config.handler(async () => {
const publicKey = (await deps.vapid?.publicKey()) ?? null;
return { available: publicKey !== null, publicKey };
}),
subscribe: authed.notifications.webPush.subscribe.handler(async ({ context, input }) => {
if (isBlockedHostname(new URL(input.endpoint).hostname.replace(/^\[|\]$/g, ""))) {
throw new ORPCError("BAD_REQUEST", {
message: "Endpoint must be a public push service",
});
}
await savePushSubscription(deps.prisma, context.actor.userId, {
kind: "webpush",
endpoint: input.endpoint,
p256dh: input.keys.p256dh,
auth: input.keys.auth,
userAgent: context.userAgent,
});
return { ok: true as const };
}),
unsubscribe: authed.notifications.webPush.unsubscribe.handler(
async ({ context, input }) => {
await removePushSubscription(deps.prisma, context.actor.userId, input.endpoint);
return { ok: true as const };
},
),
},
}, },
search: { search: {
query: authed.search.query.handler(async ({ context, input }) => ({ query: authed.search.query.handler(async ({ context, input }) => ({
@ -4894,10 +5110,13 @@ function mapUpdaterError(error: unknown): never {
async function requireProjectWorkspaceRoot( async function requireProjectWorkspaceRoot(
prisma: Pick<PrismaClient, "space">, prisma: Pick<PrismaClient, "space">,
spaceId: string, actor: Actor,
): Promise<string> { ): Promise<string> {
// Only the deployment owner may link a host folder, so only they may browse one. This also
// closes folders that other members linked before linking was limited to the owner.
if (!actor.isDeploymentOwner) throw new ORPCError("FORBIDDEN");
const space = await prisma.space.findUnique({ const space = await prisma.space.findUnique({
where: { id: spaceId }, where: { id: actor.spaceId },
select: { workspacePath: true }, select: { workspacePath: true },
}); });
if (!space?.workspacePath) { if (!space?.workspacePath) {
@ -4939,6 +5158,11 @@ async function spaceNavigationDto(
orderBy: { createdAt: "asc" }, orderBy: { createdAt: "asc" },
}); });
const spaceIds = memberships.map((membership) => membership.spaceId); const spaceIds = memberships.map((membership) => membership.spaceId);
const ownProjectMain = {
userId: actor.userId,
kind: "main",
space: { isDefault: false },
} as const;
const inactiveSpaceIds = spaceIds.filter((spaceId) => spaceId !== actor.spaceId); const inactiveSpaceIds = spaceIds.filter((spaceId) => spaceId !== actor.spaceId);
const [ const [
currentBots, currentBots,
@ -4959,13 +5183,14 @@ async function spaceNavigationDto(
// Active-only navigation lists miss archived content in other spaces; count any // Active-only navigation lists miss archived content in other spaces; count any
// bot/group in the actor's spaces (including another member's) so a shared // bot/group in the actor's spaces (including another member's) so a shared
// non-empty space cannot look empty for onboarding redirects. // non-empty space cannot look empty for onboarding redirects.
// The user's own main agent of a project is part of the project, not content to clear.
deps.prisma.bot.findMany({ deps.prisma.bot.findMany({
where: { spaceId: { in: spaceIds } }, where: { spaceId: { in: spaceIds }, NOT: ownProjectMain },
select: { spaceId: true }, select: { spaceId: true },
distinct: ["spaceId"], distinct: ["spaceId"],
}), }),
deps.prisma.chatGroup.findMany({ deps.prisma.chatGroup.findMany({
where: { spaceId: { in: spaceIds } }, where: { spaceId: { in: spaceIds }, NOT: ownProjectMain },
select: { spaceId: true }, select: { spaceId: true },
distinct: ["spaceId"], distinct: ["spaceId"],
}), }),
@ -5009,8 +5234,22 @@ async function spaceNavigationDto(
memberships.length > 1 && memberships.length > 1 &&
!spacesWithContent.has(membership.spaceId) && !spacesWithContent.has(membership.spaceId) &&
(membership.space.deletingAt === null || membership.space.deletingAt < staleClaimBefore), (membership.space.deletingAt === null || membership.space.deletingAt < staleClaimBefore),
workspace: projectWorkspaceDto(membership.space), // A linked folder is on the owner's host; other members see neither its path nor remote.
bots: spaceBots.map((bot) => ({ workspace: actor.isDeploymentOwner ? projectWorkspaceDto(membership.space) : null,
bots: spaceBots.map(spaceBotDto),
groups: spaceGroups.map(spaceGroupDto),
externalConversations: externalConversations.filter(
(conversation) => conversation.spaceId === membership.spaceId,
),
botSections: sectionsFor(membership.spaceId),
};
}),
main,
};
}
function spaceBotDto(bot: SpaceBot): SpaceBot {
return {
id: bot.id, id: bot.id,
spaceId: bot.spaceId, spaceId: bot.spaceId,
kind: bot.kind, kind: bot.kind,
@ -5024,8 +5263,11 @@ async function spaceNavigationDto(
preview: bot.preview, preview: bot.preview,
status: bot.status, status: bot.status,
updatedAt: bot.updatedAt, updatedAt: bot.updatedAt,
})), };
groups: spaceGroups.map((group) => ({ }
function spaceGroupDto(group: SpaceGroup): SpaceGroup {
return {
id: group.id, id: group.id,
spaceId: group.spaceId, spaceId: group.spaceId,
kind: group.kind, kind: group.kind,
@ -5034,16 +5276,9 @@ async function spaceNavigationDto(
sectionId: group.sectionId, sectionId: group.sectionId,
members: group.members, members: group.members,
preview: group.preview, preview: group.preview,
...(group.previewBlock ? { previewBlock: group.previewBlock } : {}),
unread: group.unread, unread: group.unread,
updatedAt: group.updatedAt, updatedAt: group.updatedAt,
})),
externalConversations: externalConversations.filter(
(conversation) => conversation.spaceId === membership.spaceId,
),
botSections: sectionsFor(membership.spaceId),
};
}),
main,
}; };
} }
@ -5425,6 +5660,7 @@ function mapRoutine(row: {
lastRunAt: Date | null; lastRunAt: Date | null;
nextRunAt: Date | null; nextRunAt: Date | null;
createdAt: Date; createdAt: Date;
coachKey: string | null;
}) { }) {
return { return {
id: row.id, id: row.id,
@ -5441,6 +5677,7 @@ function mapRoutine(row: {
lastRunAt: row.lastRunAt?.toISOString() ?? null, lastRunAt: row.lastRunAt?.toISOString() ?? null,
nextRunAt: row.nextRunAt?.toISOString() ?? null, nextRunAt: row.nextRunAt?.toISOString() ?? null,
createdAt: row.createdAt.toISOString(), createdAt: row.createdAt.toISOString(),
coachKey: CoachRoutineKeySchema.safeParse(row.coachKey).data ?? null,
}; };
} }

View File

@ -0,0 +1,143 @@
import type { MessageBlock } from "@rakazo/contracts";
import type { PrismaClient } from "@rakazo/db";
import { createDb, provisionMessagingIdentity } from "@rakazo/db";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import { loadMessagePage } from "./thread-message-pages.js";
const databaseUrl = process.env.DATABASE_URL;
const describePostgres =
process.env.VERIFY_DATABASE && databaseUrl ? describe.sequential : describe.skip;
describePostgres("loadMessagePage (PostgreSQL)", () => {
let prisma: PrismaClient;
let close: () => Promise<void>;
let identity: { userId: string; spaceId: string; botId: string; threadId: string };
let userRunId: string;
let peerRunId: string;
let seq = 0;
beforeAll(async () => {
const db = createDb(databaseUrl!);
prisma = db.prisma;
close = async () => {
await db.prisma.$disconnect();
await db.pool.end();
};
identity = await provisionMessagingIdentity(
prisma,
{ provider: "sendblue", address: `+1555${Date.now().toString().slice(-7)}` },
{ signupsEnabled: undefined, signupAllowlist: undefined },
);
const { spaceId, botId, threadId, userId } = identity;
const owner = { spaceId, botId, threadId, userId };
const run = async (trigger: string) => {
const task = await prisma.task.create({
data: { ...owner, prompt: "fixture", status: "completed" },
});
const created = await prisma.run.create({
data: { ...owner, taskId: task.id, status: "completed", trigger },
});
return created.id;
};
userRunId = await run("user_message");
peerRunId = await run("bot_message");
seq =
(
await prisma.message.aggregate({
where: { threadId: identity.threadId },
_max: { seq: true },
})
)._max.seq ?? 0;
});
afterAll(async () => {
if (identity) {
await prisma.messagingIdentity.deleteMany({ where: { userId: identity.userId } });
await prisma.organization.deleteMany({ where: { id: identity.spaceId } });
await prisma.user.deleteMany({ where: { id: identity.userId } });
}
await close?.();
});
async function message(id: string, runId: string | null, blocks: MessageBlock[]) {
seq += 1;
await prisma.message.create({
data: {
id: `${identity.threadId}-${id}`,
threadId: identity.threadId,
seq,
role: "bot",
runId,
blocks,
},
});
return seq;
}
const ids = (page: Awaited<ReturnType<typeof loadMessagePage>>) =>
page.messages.map((row) => row.id.slice(identity.threadId.length + 1));
it("fills a page with visible rows across a long stretch of hidden peer activity", async () => {
await message("older", userRunId, [{ kind: "text", text: "Older answer" }]);
await message("newer", userRunId, [{ kind: "text", text: "Newer answer" }]);
for (let index = 0; index < 30; index += 1) {
await message(`peer-steps-${index}`, peerRunId, [
{ kind: "steps", steps: [{ label: "Working", count: 1 }] },
]);
}
await message("peer-receipt", peerRunId, [
{ kind: "bot_message_received", fromBotId: "bot-2", fromBotName: "Coder", text: "Done." },
]);
await message("peer-reply", peerRunId, [{ kind: "text", text: "Peer summary" }]);
await message("orphan-run", "run-that-no-longer-exists", [{ kind: "text", text: "Kept" }]);
const first = await loadMessagePage(prisma, identity.threadId, undefined, 3);
expect(ids(first)).toEqual(["peer-receipt", "peer-reply", "orphan-run"]);
const second = await loadMessagePage(prisma, identity.threadId, first.olderCursor!, 2);
expect(ids(second)).toEqual(["older", "newer"]);
const peerView = await loadMessagePage(
prisma,
identity.threadId,
undefined,
3,
undefined,
true,
);
expect(ids(peerView)).toEqual(["peer-receipt", "peer-reply", "orphan-run"]);
const peerOlder = await loadMessagePage(
prisma,
identity.threadId,
peerView.olderCursor!,
1,
undefined,
true,
);
expect(ids(peerOlder)).toEqual(["peer-steps-29"]);
});
it("skips a receipt-only page for web but returns it when receipts are displayed", async () => {
await message("before-receipts", userRunId, [{ kind: "text", text: "Answer" }]);
await message("sent", userRunId, [
{ kind: "bot_message_sent", toBotId: "bot-2", toBotName: "Coder", text: "Check this." },
]);
await message("received", peerRunId, [
{ kind: "bot_message_received", fromBotId: "bot-2", fromBotName: "Coder", text: "Done." },
]);
const web = await loadMessagePage(prisma, identity.threadId, undefined, 2);
expect(ids(web)).toEqual(["orphan-run", "before-receipts"]);
const mobile = await loadMessagePage(
prisma,
identity.threadId,
undefined,
2,
undefined,
false,
true,
);
expect(ids(mobile)).toEqual(["sent", "received"]);
});
});

View File

@ -43,98 +43,6 @@ describe("thread message pages", () => {
).toBe(false); ).toBe(false);
}); });
it("keeps peer receipt rows when filtering peer-run output from pages", async () => {
const findMany = vi.fn(async () => [
{
id: "message-reply",
threadId: "thread-1",
seq: 3,
role: "bot",
blocks: [{ kind: "text", text: "Echoed peer reply" }],
botId: "bot-1",
replyToMessageId: null,
runId: "run-peer",
createdAt: new Date("2026-08-16T00:00:03.000Z"),
},
{
id: "message-received",
threadId: "thread-1",
seq: 2,
role: "user",
blocks: [
{
kind: "bot_message_received",
fromBotId: "bot-2",
fromBotName: "Coder",
text: "Done.",
},
],
botId: null,
replyToMessageId: null,
runId: "run-peer",
createdAt: new Date("2026-08-16T00:00:02.000Z"),
},
{
id: "message-user",
threadId: "thread-1",
seq: 1,
role: "bot",
blocks: [{ kind: "text", text: "Visible answer" }],
botId: "bot-1",
replyToMessageId: null,
runId: "run-user",
createdAt: new Date("2026-08-16T00:00:01.000Z"),
},
]);
const prisma = {
message: { findMany },
run: { findMany: vi.fn(async () => [{ id: "run-peer" }]) },
} as unknown as PrismaClient;
const page = await loadMessagePage(prisma, "thread-1", undefined, 3);
expect(page.messages.map((message) => message.id)).toEqual([
"message-user",
"message-received",
"message-reply",
]);
});
it("filters peer-run activity when its receipt is outside the loaded page", async () => {
const findMany = vi.fn(async () => [
{
id: "message-peer",
threadId: "thread-1",
seq: 2,
role: "bot",
blocks: [{ kind: "steps", steps: [{ label: "Echoed peer reply", count: 1 }] }],
botId: "bot-1",
replyToMessageId: null,
runId: "run-peer",
createdAt: new Date("2026-08-16T00:00:02.000Z"),
},
{
id: "message-user",
threadId: "thread-1",
seq: 1,
role: "bot",
blocks: [{ kind: "text", text: "Visible answer" }],
botId: "bot-1",
replyToMessageId: null,
runId: "run-user",
createdAt: new Date("2026-08-16T00:00:01.000Z"),
},
]);
const prisma = {
message: { findMany },
run: { findMany: vi.fn(async () => [{ id: "run-peer" }]) },
} as unknown as PrismaClient;
const page = await loadMessagePage(prisma, "thread-1", undefined, 2);
expect(page.messages.map((message) => message.id)).toEqual(["message-user"]);
});
it("keeps peer text around-page targets and omits peer activity", async () => { it("keeps peer text around-page targets and omits peer activity", async () => {
const findMany = vi.fn(async () => [ const findMany = vi.fn(async () => [
{ {
@ -281,45 +189,6 @@ describe("thread message pages", () => {
expect(page.messages.map((message) => message.id)).toEqual(["message-peer"]); expect(page.messages.map((message) => message.id)).toEqual(["message-peer"]);
}); });
it("scans past a page containing only hidden peer-run activity", async () => {
const row = (seq: number, runId: string, kind: "text" | "steps" = "text") => ({
id: `message-${seq}`,
threadId: "thread-1",
seq,
role: "bot",
blocks:
kind === "steps"
? [{ kind: "steps", steps: [{ label: String(seq), count: 1 }] }]
: [{ kind: "text", text: String(seq) }],
botId: "bot-1",
replyToMessageId: null,
runId,
createdAt: new Date("2026-08-16T00:00:00.000Z"),
});
const findMany = vi
.fn()
.mockResolvedValueOnce([
row(4, "run-peer", "steps"),
row(3, "run-peer", "steps"),
row(2, "run-peer", "steps"),
])
.mockResolvedValueOnce([row(1, "run-user")]);
const prisma = {
message: { findMany },
run: {
findMany: vi
.fn()
.mockResolvedValueOnce([{ id: "run-peer" }])
.mockResolvedValueOnce([]),
},
} as unknown as PrismaClient;
const page = await loadMessagePage(prisma, "thread-1", undefined, 2);
expect(page.messages.map((message) => message.id)).toEqual(["message-1"]);
expect(findMany).toHaveBeenCalledTimes(2);
});
it("scans past a receipt-only page so web can reach older user-visible rows", async () => { it("scans past a receipt-only page so web can reach older user-visible rows", async () => {
const receiptRows = [ const receiptRows = [
{ {
@ -383,21 +252,13 @@ describe("thread message pages", () => {
createdAt: new Date("2026-08-16T00:00:01.000Z"), createdAt: new Date("2026-08-16T00:00:01.000Z"),
}, },
]; ];
const findMany = vi.fn().mockResolvedValueOnce(receiptRows).mockResolvedValueOnce(olderRows); const queryRaw = vi.fn().mockResolvedValueOnce(receiptRows).mockResolvedValueOnce(olderRows);
const prisma = { const prisma = { $queryRaw: queryRaw } as unknown as PrismaClient;
message: { findMany },
run: {
findMany: vi
.fn()
.mockResolvedValueOnce([{ id: "run-peer" }])
.mockResolvedValueOnce([]),
},
} as unknown as PrismaClient;
const page = await loadMessagePage(prisma, "thread-1", undefined, 2); const page = await loadMessagePage(prisma, "thread-1", undefined, 2);
expect(page.messages.map((message) => message.id)).toEqual(["message-user"]); expect(page.messages.map((message) => message.id)).toEqual(["message-user"]);
expect(findMany).toHaveBeenCalledTimes(2); expect(queryRaw).toHaveBeenCalledTimes(2);
}); });
it("returns a receipt-only page when the client displays peer receipts", async () => { it("returns a receipt-only page when the client displays peer receipts", async () => {
@ -419,11 +280,8 @@ describe("thread message pages", () => {
runId: "run-peer", runId: "run-peer",
createdAt: new Date(`2026-08-16T00:00:0${seq}.000Z`), createdAt: new Date(`2026-08-16T00:00:0${seq}.000Z`),
}); });
const findMany = vi.fn(async () => [receipt(3), receipt(2), receipt(1)]); const queryRaw = vi.fn(async () => [receipt(3), receipt(2), receipt(1)]);
const prisma = { const prisma = { $queryRaw: queryRaw } as unknown as PrismaClient;
message: { findMany },
run: { findMany: vi.fn(async () => [{ id: "run-peer" }]) },
} as unknown as PrismaClient;
const page = await loadMessagePage(prisma, "thread-1", undefined, 2, undefined, false, true); const page = await loadMessagePage(prisma, "thread-1", undefined, 2, undefined, false, true);
@ -432,10 +290,10 @@ describe("thread message pages", () => {
"message-receipt-3", "message-receipt-3",
]); ]);
expect(page.olderCursor).toBe(2); expect(page.olderCursor).toBe(2);
expect(findMany).toHaveBeenCalledTimes(1); expect(queryRaw).toHaveBeenCalledTimes(1);
}); });
it("queries before the cursor and returns an ascending bounded page", async () => { it("queries the bot messages view before the cursor as an ascending bounded page", async () => {
const findMany = vi.fn(async () => const findMany = vi.fn(async () =>
[5, 4, 3].map((seq) => ({ [5, 4, 3].map((seq) => ({
id: `message-${seq}`, id: `message-${seq}`,
@ -449,7 +307,7 @@ describe("thread message pages", () => {
); );
const prisma = { message: { findMany } } as unknown as PrismaClient; const prisma = { message: { findMany } } as unknown as PrismaClient;
const page = await loadMessagePage(prisma, "thread-1", 6, 2); const page = await loadMessagePage(prisma, "thread-1", 6, 2, undefined, true);
expect(findMany).toHaveBeenCalledWith({ expect(findMany).toHaveBeenCalledWith({
where: { threadId: "thread-1", seq: { lt: 6 } }, where: { threadId: "thread-1", seq: { lt: 6 } },
@ -461,7 +319,7 @@ describe("thread message pages", () => {
}); });
it("ends pagination when the database returns no lookahead row", async () => { it("ends pagination when the database returns no lookahead row", async () => {
const findMany = vi.fn(async () => [ const queryRaw = vi.fn(async () => [
{ {
id: "message-0", id: "message-0",
threadId: "thread-1", threadId: "thread-1",
@ -472,7 +330,7 @@ describe("thread message pages", () => {
createdAt: new Date("2026-08-16T00:00:00.000Z"), createdAt: new Date("2026-08-16T00:00:00.000Z"),
}, },
]); ]);
const prisma = { message: { findMany } } as unknown as PrismaClient; const prisma = { $queryRaw: queryRaw } as unknown as PrismaClient;
const page = await loadMessagePage(prisma, "thread-1", 1, 2); const page = await loadMessagePage(prisma, "thread-1", 1, 2);

View File

@ -1,9 +1,18 @@
import type { MessageBlock, ThreadMessage, ThreadMessagePage } from "@rakazo/contracts"; import type { MessageBlock, ThreadMessage, ThreadMessagePage } from "@rakazo/contracts";
import { isPeerReceiptBlocks } from "@rakazo/core"; import { isPeerReceiptBlocks } from "@rakazo/core";
import type { Prisma, PrismaClient } from "@rakazo/db"; import type { PrismaClient } from "@rakazo/db";
import { Prisma } from "@rakazo/db";
type MessageDb = PrismaClient | Prisma.TransactionClient; type MessageDb = PrismaClient | Prisma.TransactionClient;
/** Peer-run rows that stay in the normal transcript: receipts, ask cards and the bot's own reply. */
const PEER_VISIBLE_BLOCK_KINDS: readonly string[] = [
"bot_message_sent",
"bot_message_received",
"ask",
"text",
];
export async function loadMessagePage( export async function loadMessagePage(
prisma: MessageDb, prisma: MessageDb,
threadId: string, threadId: string,
@ -47,35 +56,67 @@ export async function loadMessagePage(
} }
} }
let cursor = before; if (includePeerRuns) {
while (true) {
const rows = await prisma.message.findMany({ const rows = await prisma.message.findMany({
where: { where: {
threadId, threadId,
...(cursor === undefined ? {} : { seq: { lt: cursor } }), ...(before === undefined ? {} : { seq: { lt: before } }),
}, },
orderBy: { seq: "desc" }, orderBy: { seq: "desc" },
take: pageSize + 1, take: pageSize + 1,
}); });
const hasOlder = rows.length > pageSize; return toPage(threadId, rows, pageSize);
const pageRows = rows.slice(0, pageSize).reverse(); }
const visibleRows = includePeerRuns ? pageRows : await withoutPeerRunMessages(prisma, pageRows);
let cursor = before;
while (true) {
const rows = await visibleMessageRows(prisma, threadId, cursor, pageSize + 1);
const page = toPage(threadId, rows, pageSize);
// Web hides receipts client-side, so its receipt-only pages keep scanning. // Web hides receipts client-side, so its receipt-only pages keep scanning.
// Mobile explicitly retains them and must receive each page for pagination. // Mobile explicitly retains them and must receive each page for pagination.
const hasSubstantive = visibleRows.some( const hasSubstantive = page.messages.some((message) => !isPeerReceiptBlocks(message.blocks));
(row) => !isPeerReceiptBlocks(row.blocks as MessageBlock[]), if (hasSubstantive || includePeerReceipts || page.olderCursor === null) return page;
); cursor = page.olderCursor;
if (hasSubstantive || includePeerReceipts || !hasOlder || includePeerRuns) { }
}
type MessageRow = Parameters<typeof toThreadMessage>[0];
function toPage(threadId: string, rows: MessageRow[], pageSize: number): ThreadMessagePage {
const pageRows = rows.slice(0, pageSize).reverse();
return { return {
threadId, threadId,
messages: visibleRows.map(toThreadMessage), messages: pageRows.map(toThreadMessage),
olderCursor: hasOlder ? (pageRows[0]?.seq ?? null) : null, olderCursor: rows.length > pageSize ? (pageRows[0]?.seq ?? null) : null,
}; };
} }
// TODO: only rescan when a raw page is entirely peer output. Consider a run relation if
// long peer-only histories make this path hot. /**
cursor = pageRows[0]?.seq; * Newest-first transcript rows with peer-run activity excluded in the database, so a long
} * peer-only stretch costs one query instead of a rescan per hidden page.
*/
function visibleMessageRows(
prisma: MessageDb,
threadId: string,
before: number | undefined,
limit: number,
): Promise<MessageRow[]> {
return prisma.$queryRaw<MessageRow[]>`
SELECT m.id, m."threadId", m.seq, m.role, m.blocks, m."botId", m."replyToMessageId",
m."replyQuote", m."runId", m."createdAt"
FROM messages m
LEFT JOIN runs r ON r.id = m."runId"
WHERE m."threadId" = ${threadId}
${before === undefined ? Prisma.empty : Prisma.sql`AND m.seq < ${before}`}
AND (r.trigger IS DISTINCT FROM 'bot_message' OR ${Prisma.join(
PEER_VISIBLE_BLOCK_KINDS.map(
(kind) => Prisma.sql`m.blocks @> ${JSON.stringify([{ kind }])}::jsonb`,
),
" OR ",
)})
ORDER BY m.seq DESC
LIMIT ${limit}
`;
} }
export async function loadAllMessages( export async function loadAllMessages(
@ -106,15 +147,8 @@ async function withoutPeerRunMessages<T extends { runId: string | null; blocks:
const peerRunIds = new Set(peerRuns.map((run) => run.id)); const peerRunIds = new Set(peerRuns.map((run) => run.id));
return rows.filter((row) => { return rows.filter((row) => {
if (!row.runId || !peerRunIds.has(row.runId)) return true; if (!row.runId || !peerRunIds.has(row.runId)) return true;
// Keep peer receipts (chips), ask cards, and the bot's own text reply.
const blocks = row.blocks as MessageBlock[]; const blocks = row.blocks as MessageBlock[];
return blocks.some( return blocks.some((block) => PEER_VISIBLE_BLOCK_KINDS.includes(block.kind));
(block) =>
block.kind === "bot_message_sent" ||
block.kind === "bot_message_received" ||
block.kind === "ask" ||
block.kind === "text",
);
}); });
} }
@ -159,10 +193,8 @@ export function shouldForwardPeerThreadEvent(event: {
!!block && !!block &&
typeof block === "object" && typeof block === "object" &&
"kind" in block && "kind" in block &&
(block.kind === "bot_message_received" || typeof block.kind === "string" &&
block.kind === "bot_message_sent" || PEER_VISIBLE_BLOCK_KINDS.includes(block.kind),
block.kind === "ask" ||
block.kind === "text"),
) )
); );
} }

View File

@ -183,7 +183,7 @@ describe("threadSnapshot", () => {
}, },
]); ]);
const tx = { const tx = {
$queryRaw: vi.fn().mockResolvedValue([{ id: "thread-1" }]), $queryRaw: threadQueries(),
message: { findMany: vi.fn().mockResolvedValue([]) }, message: { findMany: vi.fn().mockResolvedValue([]) },
event: { event: {
findFirst: vi.fn().mockResolvedValue({ seq: 4 }), findFirst: vi.fn().mockResolvedValue({ seq: 4 }),
@ -203,7 +203,10 @@ describe("threadSnapshot", () => {
const snapshot = await threadSnapshot({ prisma }, target); const snapshot = await threadSnapshot({ prisma }, target);
expect(tx.$queryRaw).toHaveBeenCalledOnce(); expect(tx.$queryRaw).toHaveBeenCalledWith(
expect.arrayContaining([expect.stringContaining("FOR SHARE")]),
"thread-1",
);
expect(findManyEvents).toHaveBeenCalledWith( expect(findManyEvents).toHaveBeenCalledWith(
expect.objectContaining({ expect.objectContaining({
where: expect.objectContaining({ where: expect.objectContaining({
@ -245,7 +248,7 @@ describe("threadSnapshot", () => {
const findManyEvents = vi.fn(); const findManyEvents = vi.fn();
const findFirstRun = botRunFindFirst([run]); const findFirstRun = botRunFindFirst([run]);
const tx = { const tx = {
$queryRaw: vi.fn().mockResolvedValue([{ id: "thread-1" }]), $queryRaw: threadQueries(),
message: { findMany: vi.fn().mockResolvedValue([]) }, message: { findMany: vi.fn().mockResolvedValue([]) },
event: { event: {
findFirst: vi.fn().mockResolvedValue(null), findFirst: vi.fn().mockResolvedValue(null),
@ -330,7 +333,7 @@ describe("threadSnapshot", () => {
prisma: { prisma: {
$transaction: vi.fn(async (callback: (client: unknown) => unknown) => $transaction: vi.fn(async (callback: (client: unknown) => unknown) =>
callback({ callback({
$queryRaw: vi.fn().mockResolvedValue([{ id: "thread-1" }]), $queryRaw: threadQueries(),
message: { findMany: vi.fn().mockResolvedValue([]) }, message: { findMany: vi.fn().mockResolvedValue([]) },
event: { event: {
findFirst: vi.fn().mockResolvedValue(null), findFirst: vi.fn().mockResolvedValue(null),
@ -385,7 +388,7 @@ describe("threadSnapshot", () => {
}; };
const findFirstRun = botRunFindFirst([failed, completed]); const findFirstRun = botRunFindFirst([failed, completed]);
const tx = { const tx = {
$queryRaw: vi.fn().mockResolvedValue([{ id: "thread-1" }]), $queryRaw: threadQueries(),
message: { findMany: vi.fn().mockResolvedValue([]) }, message: { findMany: vi.fn().mockResolvedValue([]) },
event: { event: {
findFirst: vi.fn().mockResolvedValue(null), findFirst: vi.fn().mockResolvedValue(null),
@ -421,7 +424,7 @@ describe("threadSnapshot", () => {
const findManyEvents = vi.fn(); const findManyEvents = vi.fn();
const findFirstRun = botRunFindFirst([]); const findFirstRun = botRunFindFirst([]);
const tx = { const tx = {
$queryRaw: vi.fn().mockResolvedValue([{ id: "thread-1" }]), $queryRaw: threadQueries(),
message: { findMany: vi.fn().mockResolvedValue([]) }, message: { findMany: vi.fn().mockResolvedValue([]) },
event: { event: {
findFirst: vi.fn().mockResolvedValue(null), findFirst: vi.fn().mockResolvedValue(null),
@ -925,9 +928,16 @@ function groupRunFindMany(input: { active?: unknown[]; terminals?: unknown[] })
); );
} }
/** The snapshot's thread lock finds the thread; its message page query finds no rows. */
function threadQueries() {
return vi.fn(async (sql: TemplateStringsArray) =>
sql.join("").includes("FOR SHARE") ? [{ id: "thread-1" }] : [],
);
}
function groupPrisma(findManyRuns: ReturnType<typeof groupRunFindMany>) { function groupPrisma(findManyRuns: ReturnType<typeof groupRunFindMany>) {
const tx = { const tx = {
$queryRaw: vi.fn().mockResolvedValue([{ id: "thread-1" }]), $queryRaw: threadQueries(),
message: { findMany: vi.fn().mockResolvedValue([]) }, message: { findMany: vi.fn().mockResolvedValue([]) },
event: { event: {
findFirst: vi.fn().mockResolvedValue(null), findFirst: vi.fn().mockResolvedValue(null),

View File

@ -1,96 +0,0 @@
import { TRADING_BOT_PROXY_PATH } from "@rakazo/core";
import { Hono } from "hono";
import { describe, expect, it, vi } from "vitest";
import { mountTradingBotStatus } from "./trading-bot-proxy.js";
function proxyApp(options: {
baseUrl?: string;
authorize?: (request: Request) => Promise<boolean>;
fetchImpl?: typeof fetch;
}) {
const app = new Hono();
mountTradingBotStatus(app, {
baseUrl: options.baseUrl,
authorize: options.authorize ?? (async () => true),
fetchImpl: options.fetchImpl,
});
return app;
}
describe("trading bot status proxy", () => {
it("stays idle when TRADING_BOT_URL is unset and does not call upstream", async () => {
const fetchImpl = vi.fn();
const response = await proxyApp({ fetchImpl }).request(TRADING_BOT_PROXY_PATH);
expect(response.status).toBe(200);
expect(await response.json()).toEqual({ configured: false });
expect(fetchImpl).not.toHaveBeenCalled();
});
it("requires a session before it proxies a configured origin", async () => {
const fetchImpl = vi.fn();
const response = await proxyApp({
baseUrl: "http://bot.test:8000",
authorize: async () => false,
fetchImpl,
}).request(TRADING_BOT_PROXY_PATH, { headers: { cookie: "session=secret" } });
expect(response.status).toBe(401);
expect(fetchImpl).not.toHaveBeenCalled();
});
it("returns the mapped status and does not forward browser credentials", async () => {
const fetchImpl = vi.fn(
async () =>
new Response(
JSON.stringify({ mode: "LIVE", adapter_count: 2, last_error: "adapter timeout" }),
{ status: 200, headers: { "content-type": "application/json" } },
),
);
const response = await proxyApp({
baseUrl: "http://bot.test:8000",
fetchImpl,
}).request(TRADING_BOT_PROXY_PATH, {
headers: { cookie: "session=secret", authorization: "Bearer browser-token" },
});
expect(response.status).toBe(200);
expect(await response.json()).toEqual({
configured: true,
reachable: true,
status: {
mode: "live",
kind: "live",
adapterCount: 2,
lastError: "adapter timeout",
},
});
expect(fetchImpl).toHaveBeenCalledTimes(1);
const [url, init] = fetchImpl.mock.calls[0] as [string, RequestInit];
expect(url).toBe("http://bot.test:8000/bot/api/status");
const headers = new Headers(init.headers);
expect(headers.get("cookie")).toBeNull();
expect(headers.get("authorization")).toBeNull();
expect(init.redirect).toBe("error");
});
it("hides an unmapped body and an upstream failure instead of claiming live", async () => {
const unmapped = vi.fn(
async () =>
new Response(JSON.stringify({ status: "ok" }), {
status: 200,
headers: { "content-type": "application/json" },
}),
);
const unknown = await proxyApp({
baseUrl: "http://bot.test:8000",
fetchImpl: unmapped,
}).request(TRADING_BOT_PROXY_PATH);
expect(await unknown.json()).toEqual({ configured: true, reachable: true, status: null });
const down = vi.fn(async () => new Response("nope", { status: 500 }));
const offline = await proxyApp({
baseUrl: "http://bot.test:8000",
fetchImpl: down,
}).request(TRADING_BOT_PROXY_PATH);
expect(offline.status).toBe(502);
expect(await offline.json()).toEqual({ configured: true, reachable: false });
});
});

View File

@ -1,79 +0,0 @@
import {
mapTradingBotStatus,
readBoundedJsonResponse,
TRADING_BOT_PROXY_PATH,
TRADING_BOT_STATUS_MAX_BYTES,
TRADING_BOT_STATUS_TIMEOUT_MS,
tradingBotStatusUrl,
} from "@rakazo/core";
import type { Hono } from "hono";
export function mountTradingBotStatus(
app: Hono,
options: {
/** Normalized http(s) origin. Unset leaves the route idle for SaaS builds. */
baseUrl: string | undefined;
authorize: (request: Request) => Promise<boolean>;
fetchImpl?: typeof fetch;
},
) {
const fetchImpl = options.fetchImpl ?? fetch;
app.get(TRADING_BOT_PROXY_PATH, async (c) => {
c.header("cache-control", "no-store");
if (!options.baseUrl) return c.json({ configured: false });
const allowed = await options.authorize(c.req.raw).catch(() => false);
if (!allowed) return c.json({ error: "Unauthorized" }, 401);
const timeout = linkAbort(c.req.raw.signal, TRADING_BOT_STATUS_TIMEOUT_MS);
try {
const response = await fetchImpl(tradingBotStatusUrl(options.baseUrl), {
method: "GET",
redirect: "error",
cache: "no-store",
signal: timeout.signal,
headers: { accept: "application/json" },
});
if (!response.ok) {
cancelBody(response);
return c.json({ configured: true, reachable: false }, 502);
}
const body = await readBoundedJsonResponse<unknown>(
response,
TRADING_BOT_STATUS_MAX_BYTES,
timeout.signal,
);
const status = mapTradingBotStatus(body);
return c.json({ configured: true, reachable: true, status: status ?? null });
} catch {
return c.json({ configured: true, reachable: false }, 502);
} finally {
timeout.cancel();
}
});
}
function linkAbort(
parent: AbortSignal,
timeoutMs: number,
): { signal: AbortSignal; cancel: () => void } {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), timeoutMs);
const onParent = () => controller.abort();
if (parent.aborted) controller.abort();
else parent.addEventListener("abort", onParent);
return {
signal: controller.signal,
cancel: () => {
clearTimeout(timer);
parent.removeEventListener("abort", onParent);
},
};
}
function cancelBody(response: Response): void {
try {
void Promise.resolve(response.body?.cancel()).catch(() => undefined);
} catch {
// Best-effort. The caller still reports the bot as unreachable.
}
}

View File

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

View File

@ -0,0 +1,155 @@
import http from "node:http";
import path from "node:path";
import { _electron as electron, expect, test } from "@playwright/test";
import type { RakazoDesktop, RakazoDesktopBrowserPreviewState } from "@rakazo/contracts";
const fixture = `<!doctype html>
<html lang="en">
<head><meta charset="utf-8"><title>Rakazo desktop browser</title></head>
<body><main>App with a browser panel</main></body>
</html>`;
type Bridge = NonNullable<RakazoDesktop["browserPreview"]>;
test("the in-app browser is a native view the app window lays over its panel", async () => {
const pages = http.createServer((req, res) => {
res.setHeader("content-type", "text/html");
if (req.url === "/second") {
res.end("<!doctype html><title>Second page</title><p>second</p>");
return;
}
res.end(`<!doctype html><title>First page</title>
<a id="next" href="/second">next</a>
<a id="popup" href="/second" target="_blank">popup</a>
<a id="file" href="file:///etc/hosts">file</a>`);
});
await new Promise<void>((resolve) => pages.listen(0, "127.0.0.1", resolve));
const address = pages.address();
if (!address || typeof address === "string") throw new Error("No fixture server");
const origin = `http://127.0.0.1:${address.port}`;
const app = await electron.launch({
args: ["."],
cwd: path.resolve(import.meta.dirname, ".."),
env: {
...process.env,
RAKAZO_WEB_URL: `data:text/html;charset=utf-8,${encodeURIComponent(fixture)}`,
},
});
try {
const page = await app.firstWindow();
await expect(page.getByText("App with a browser panel")).toBeVisible();
const call = <K extends keyof Bridge>(method: K, ...args: unknown[]) =>
page.evaluate(
([name, values]) => {
const bridge = (window as typeof window & { rakazoDesktop?: RakazoDesktop }).rakazoDesktop
?.browserPreview as unknown as Record<
string,
(...input: unknown[]) => Promise<unknown>
>;
return bridge[name as string]!(...(values as unknown[]));
},
[method, args] as const,
);
const state = () => call("state") as Promise<RakazoDesktopBrowserPreviewState>;
const view = () =>
app.evaluate(({ BrowserWindow }) => {
const win = BrowserWindow.getAllWindows()[0]!;
const child = win.contentView.children[0] as
| (Electron.View & { webContents?: Electron.WebContents })
| undefined;
return child
? {
count: win.contentView.children.length,
visible: child.getVisible(),
bounds: child.getBounds(),
url: child.webContents?.getURL(),
partition: child.webContents?.session === win.webContents.session,
sandbox: child.webContents?.getLastWebPreferences()?.sandbox,
nodeIntegration: child.webContents?.getLastWebPreferences()?.nodeIntegration,
}
: null;
});
// Pages load in a native view in their own session, never the app's.
await call("navigate", `${origin}/`);
await expect.poll(async () => (await state()).title).toBe("First page");
expect(await view()).toMatchObject({
count: 1,
visible: false,
url: `${origin}/`,
partition: false,
sandbox: true,
nodeIntegration: false,
});
await call("show", { x: 400, y: 50, width: 300, height: 200 });
expect(await view()).toMatchObject({
visible: true,
bounds: { x: 400, y: 50, width: 300, height: 200 },
});
// Renderer bounds outside the window are clamped to it.
await call("show", { x: -10, y: -10, width: 100_000, height: 100_000 });
const [width, height] = await app.evaluate(({ BrowserWindow }) =>
BrowserWindow.getAllWindows()[0]!.getContentSize(),
);
expect((await view())?.bounds).toEqual({ x: 0, y: 0, width, height });
const contents = () =>
app.evaluate(({ BrowserWindow }, script) => {
const win = BrowserWindow.getAllWindows()[0]!;
const child = win.contentView.children[0] as Electron.View & {
webContents: Electron.WebContents;
};
return child.webContents.executeJavaScript(script, true);
}, "document.getElementById('next').click()");
await contents();
await expect.poll(async () => (await state()).title).toBe("Second page");
expect(await state()).toMatchObject({ canGoBack: true, canGoForward: false, error: null });
await call("back");
await expect.poll(async () => (await state()).title).toBe("First page");
expect(await state()).toMatchObject({ canGoForward: true });
await call("forward");
await expect.poll(async () => (await state()).url).toBe(`${origin}/second`);
// New-window links stay in the one view; file links never load.
await call("back");
await expect.poll(async () => (await state()).title).toBe("First page");
await app.evaluate(({ BrowserWindow }) => {
const child = BrowserWindow.getAllWindows()[0]!.contentView.children[0] as Electron.View & {
webContents: Electron.WebContents;
};
return child.webContents.executeJavaScript("document.getElementById('popup').click()", true);
});
await expect.poll(async () => (await state()).url).toBe(`${origin}/second`);
expect(await app.evaluate(({ BrowserWindow }) => BrowserWindow.getAllWindows().length)).toBe(1);
await call("back");
await expect.poll(async () => (await state()).title).toBe("First page");
await app.evaluate(({ BrowserWindow }) => {
const child = BrowserWindow.getAllWindows()[0]!.contentView.children[0] as Electron.View & {
webContents: Electron.WebContents;
};
return child.webContents.executeJavaScript("document.getElementById('file').click()", true);
});
await page.waitForTimeout(300);
expect((await state()).url).toBe(`${origin}/`);
await expect(call("navigate", "file:///etc/hosts")).rejects.toThrow(/http/);
// A dev server that is not running yet shows as an error, then recovers on reload.
pages.close();
await new Promise((resolve) => setTimeout(resolve, 100));
await call("navigate", `${origin}/gone`);
await expect.poll(async () => (await state()).error).toMatch(/ERR_CONNECTION_REFUSED/);
await call("hide");
expect((await view())?.visible).toBe(false);
} finally {
await app.close();
pages.closeAllConnections();
pages.close();
}
});

View File

@ -42,6 +42,7 @@ test("launches with a narrow preload bridge and an isolated renderer", async ()
}); });
expect(renderer.bridgeKeys).toEqual([ expect(renderer.bridgeKeys).toEqual([
"browserPreview",
"files", "files",
"localSettings", "localSettings",
"oauth", "oauth",

View File

@ -0,0 +1,96 @@
import { describe, expect, it } from "vitest";
import {
previewBounds,
previewLoadError,
previewShortcut,
previewUrl,
} from "./browser-preview-policy.js";
describe("previewUrl", () => {
it("accepts only http(s) without credentials", () => {
expect(previewUrl("http://localhost:5173")).toBe("http://localhost:5173/");
expect(previewUrl("https://example.com/a?b=1")).toBe("https://example.com/a?b=1");
for (const value of [
"file:///etc/hosts",
"javascript:alert(1)",
"chrome://settings",
"https://user:fake-password@example.com",
"not a url",
42,
`https://example.com/${"a".repeat(9000)}`,
]) {
expect(previewUrl(value)).toBeNull();
}
});
});
describe("previewBounds", () => {
const content = { width: 1200, height: 800 };
it("rounds and applies the app zoom factor", () => {
expect(previewBounds({ x: 800.4, y: 40.6, width: 399.5, height: 700 }, content)).toEqual({
x: 800,
y: 41,
width: 400,
height: 700,
});
expect(previewBounds({ x: 100, y: 10, width: 200, height: 100 }, content, 1.25)).toEqual({
x: 125,
y: 13,
width: 250,
height: 125,
});
});
it("clamps to the window and rejects empty or malformed rectangles", () => {
expect(previewBounds({ x: -50, y: -10, width: 5000, height: 5000 }, content)).toEqual({
x: 0,
y: 0,
width: 1200,
height: 800,
});
expect(previewBounds({ x: 1300, y: 0, width: 100, height: 100 }, content)).toBeNull();
expect(previewBounds({ x: 0, y: 0, width: 0, height: 100 }, content)).toBeNull();
expect(previewBounds({ x: "1", y: 0, width: 1, height: 1 }, content)).toBeNull();
expect(previewBounds({ x: Number.NaN, y: 0, width: 1, height: 1 }, content)).toBeNull();
expect(previewBounds(null, content)).toBeNull();
});
});
describe("previewShortcut", () => {
const key = (overrides: Partial<Parameters<typeof previewShortcut>[0]>) => ({
type: "keyDown",
key: "r",
control: false,
meta: false,
alt: false,
shift: false,
...overrides,
});
it("maps platform browser shortcuts", () => {
expect(previewShortcut(key({ meta: true }), "darwin")).toBe("reload");
expect(previewShortcut(key({ meta: true, shift: true, key: "R" }), "darwin")).toBe(
"hard-reload",
);
expect(previewShortcut(key({ control: true }), "linux")).toBe("reload");
expect(previewShortcut(key({ meta: true, key: "[" }), "darwin")).toBe("back");
expect(previewShortcut(key({ alt: true, key: "ArrowRight" }), "win32")).toBe("forward");
expect(previewShortcut(key({ key: "F12" }), "linux")).toBe("devtools");
expect(previewShortcut(key({ key: "F5" }), "win32")).toBe("reload");
});
it("leaves typing alone", () => {
expect(previewShortcut(key({}), "darwin")).toBeNull();
expect(previewShortcut(key({ control: true }), "darwin")).toBeNull();
expect(previewShortcut(key({ type: "keyUp", meta: true }), "darwin")).toBeNull();
expect(previewShortcut(key({ key: "ArrowLeft" }), "linux")).toBeNull();
});
});
describe("previewLoadError", () => {
it("ignores superseded navigations", () => {
expect(previewLoadError(-3, "ERR_ABORTED")).toBeNull();
expect(previewLoadError(-102, "ERR_CONNECTION_REFUSED")).toBe("ERR_CONNECTION_REFUSED");
});
});

View File

@ -0,0 +1,76 @@
import type { RakazoDesktopBrowserBounds } from "@rakazo/contracts";
/** Only plain http(s) pages load in the in-app browser; no files, schemes or URL credentials. */
export function previewUrl(value: unknown): string | null {
if (typeof value !== "string" || value.length > 8192) return null;
try {
const url = new URL(value);
if (url.protocol !== "http:" && url.protocol !== "https:") return null;
if (url.username || url.password) return null;
return url.toString();
} catch {
return null;
}
}
/**
* Renderer-reported CSS-pixel bounds, converted to window DIPs and clamped to the content area
* so a compromised renderer cannot place the view outside the panel it reserved.
*/
export function previewBounds(
value: unknown,
content: { width: number; height: number },
zoomFactor = 1,
): RakazoDesktopBrowserBounds | null {
if (!value || typeof value !== "object") return null;
const raw = value as Record<string, unknown>;
const numbers = [raw.x, raw.y, raw.width, raw.height];
if (!numbers.every((n) => typeof n === "number" && Number.isFinite(n))) return null;
const scale = Number.isFinite(zoomFactor) && zoomFactor > 0 ? zoomFactor : 1;
const [x, y, width, height] = (numbers as number[]).map((n) => Math.round(n * scale)) as [
number,
number,
number,
number,
];
const left = Math.min(Math.max(0, x), content.width);
const top = Math.min(Math.max(0, y), content.height);
const right = Math.min(Math.max(left, x + width), content.width);
const bottom = Math.min(Math.max(top, y + height), content.height);
if (right - left < 1 || bottom - top < 1) return null;
return { x: left, y: top, width: right - left, height: bottom - top };
}
export type PreviewShortcut = "reload" | "hard-reload" | "back" | "forward" | "devtools";
/** Browser keyboard shortcuts while focus is inside the page. */
export function previewShortcut(
input: {
type: string;
key: string;
control: boolean;
meta: boolean;
alt: boolean;
shift: boolean;
},
platform: string,
): PreviewShortcut | null {
if (input.type !== "keyDown") return null;
const mod = platform === "darwin" ? input.meta : input.control;
const key = input.key.length === 1 ? input.key.toLowerCase() : input.key;
if (key === "F5") return input.shift ? "hard-reload" : "reload";
if (key === "F12") return "devtools";
if (mod && key === "r") return input.shift ? "hard-reload" : "reload";
if (mod && input.alt && key === "i") return "devtools";
if (platform === "darwin" ? input.meta && !input.alt : input.alt && !input.control) {
if (key === (platform === "darwin" ? "[" : "ArrowLeft")) return "back";
if (key === (platform === "darwin" ? "]" : "ArrowRight")) return "forward";
}
return null;
}
/** Load failures worth showing; an aborted load (-3) is a superseded navigation, not an error. */
export function previewLoadError(errorCode: number, description: string): string | null {
if (errorCode === -3 || errorCode === 0) return null;
return description || `Load failed (${errorCode})`;
}

View File

@ -0,0 +1,189 @@
import type { RakazoDesktopBrowserPreviewState } from "@rakazo/contracts";
import { type BrowserWindow, WebContentsView } from "electron";
import {
previewBounds,
previewLoadError,
previewShortcut,
previewUrl,
} from "./browser-preview-policy.js";
/** Pages share one persistent profile, separate from the app's own session and cookies. */
const PREVIEW_PARTITION = "persist:rakazo-browser";
export const PREVIEW_STATE_CHANNEL = "desktop.browserPreview.state";
/**
* The in-app browser's native page: one WebContentsView laid over the panel the app window
* reserves. It belongs to one app window at a time and is torn down with it.
*/
export class BrowserPreviewController {
private host: BrowserWindow | null = null;
private view: WebContentsView | null = null;
private error: string | null = null;
state(win: BrowserWindow): RakazoDesktopBrowserPreviewState {
const contents = this.host === win ? this.view?.webContents : undefined;
if (!contents || contents.isDestroyed()) {
return {
url: "",
title: "",
loading: false,
canGoBack: false,
canGoForward: false,
error: null,
};
}
const url = contents.getURL();
return {
url,
title: contents.getTitle() === url ? "" : contents.getTitle(),
loading: contents.isLoading(),
canGoBack: contents.navigationHistory.canGoBack(),
canGoForward: contents.navigationHistory.canGoForward(),
error: this.error,
};
}
show(win: BrowserWindow, rawBounds: unknown) {
const view = this.viewFor(win);
const [width, height] = win.getContentSize();
const bounds = previewBounds(
rawBounds,
{ width: width ?? 0, height: height ?? 0 },
win.webContents.getZoomFactor(),
);
if (!bounds) {
view.setVisible(false);
return;
}
view.setBounds(bounds);
view.setVisible(true);
}
hide(win: BrowserWindow) {
if (this.host === win) this.view?.setVisible(false);
}
async navigate(win: BrowserWindow, rawUrl: unknown) {
const url = previewUrl(rawUrl);
if (!url) throw new Error("Only http(s) pages can open here.");
this.error = null;
// Failures surface through did-fail-load as page state, not as a rejected call.
await this.viewFor(win)
.webContents.loadURL(url)
.catch(() => undefined);
}
back(win: BrowserWindow) {
const history = this.contentsFor(win)?.navigationHistory;
if (history?.canGoBack()) history.goBack();
}
forward(win: BrowserWindow) {
const history = this.contentsFor(win)?.navigationHistory;
if (history?.canGoForward()) history.goForward();
}
reload(win: BrowserWindow, ignoreCache = false) {
const contents = this.contentsFor(win);
if (!contents?.getURL()) return;
this.error = null;
if (ignoreCache) contents.reloadIgnoringCache();
else contents.reload();
}
stop(win: BrowserWindow) {
this.contentsFor(win)?.stop();
}
openDevTools(win: BrowserWindow) {
const contents = this.contentsFor(win);
if (contents?.getURL()) contents.openDevTools({ mode: "detach" });
}
private contentsFor(win: BrowserWindow) {
const contents = this.host === win ? this.view?.webContents : undefined;
return contents && !contents.isDestroyed() ? contents : undefined;
}
private viewFor(win: BrowserWindow): WebContentsView {
if (this.host === win && this.view && !this.view.webContents.isDestroyed()) return this.view;
this.destroy();
const view = new WebContentsView({
webPreferences: {
partition: PREVIEW_PARTITION,
sandbox: true,
contextIsolation: true,
nodeIntegration: false,
webSecurity: true,
spellcheck: false,
},
});
view.setVisible(false);
const contents = view.webContents;
const publish = () => {
if (!win.isDestroyed()) win.webContents.send(PREVIEW_STATE_CHANNEL, this.state(win));
};
// One tab: links that want a new window open here instead.
contents.setWindowOpenHandler(({ url }) => {
const target = previewUrl(url);
if (target) void contents.loadURL(target).catch(() => undefined);
return { action: "deny" };
});
contents.on("will-navigate", (event, url) => {
if (!previewUrl(url)) event.preventDefault();
});
contents.on("will-redirect", (event, url) => {
if (!previewUrl(url)) event.preventDefault();
});
contents.on("did-start-loading", publish);
contents.on("did-stop-loading", publish);
contents.on("did-navigate", () => {
this.error = null;
publish();
});
contents.on("did-navigate-in-page", publish);
contents.on("page-title-updated", publish);
contents.on("did-fail-load", (_event, code, description, _url, isMainFrame) => {
if (!isMainFrame) return;
const error = previewLoadError(code, description);
if (error === null) return;
this.error = error;
publish();
});
contents.on("render-process-gone", (_event, details) => {
this.error = details.reason === "clean-exit" ? null : "The page stopped responding.";
publish();
});
contents.on("before-input-event", (event, input) => {
const shortcut = previewShortcut(input, process.platform);
if (!shortcut) return;
event.preventDefault();
if (shortcut === "reload" || shortcut === "hard-reload") {
this.reload(win, shortcut === "hard-reload");
} else if (shortcut === "back") {
this.back(win);
} else if (shortcut === "forward") {
this.forward(win);
} else {
this.openDevTools(win);
}
});
win.contentView.addChildView(view);
win.once("closed", () => {
if (this.host === win) this.destroy();
});
this.host = win;
this.view = view;
return view;
}
private destroy() {
const { host, view } = this;
this.host = null;
this.view = null;
this.error = null;
if (!view) return;
if (host && !host.isDestroyed()) host.contentView.removeChildView(view);
if (!view.webContents.isDestroyed()) view.webContents.close();
}
}

View File

@ -21,6 +21,7 @@ import {
LAUNCH_CHECK_DELAY_MS, LAUNCH_CHECK_DELAY_MS,
} from "./auto-update.js"; } from "./auto-update.js";
import { openBrowserAuth } from "./browser-auth.js"; import { openBrowserAuth } from "./browser-auth.js";
import { BrowserPreviewController } from "./browser-preview.js";
import { DOCKER_INSTALL_LINKS, isDesktopSetupLink, runDocker } from "./docker-cli.js"; import { DOCKER_INSTALL_LINKS, isDesktopSetupLink, runDocker } from "./docker-cli.js";
import { requestLocalSettings } from "./local-settings.js"; import { requestLocalSettings } from "./local-settings.js";
import { import {
@ -1130,6 +1131,35 @@ app.whenReady().then(async () => {
}, },
); );
ipcMain.handle("desktop.platform", () => process.platform); ipcMain.handle("desktop.platform", () => process.platform);
const browserPreview = new BrowserPreviewController();
// The in-app browser answers only the app window's own top frame, never a page inside it.
const previewHost = (event: Electron.IpcMainInvokeEvent) => {
if (!fromMainWindow(event) || event.senderFrame !== event.sender.mainFrame) {
throw new Error("The in-app browser is not available here.");
}
return mainWindow!;
};
ipcMain.handle("desktop.browserPreview.state", (event) =>
browserPreview.state(previewHost(event)),
);
ipcMain.handle("desktop.browserPreview.show", (event, bounds: unknown) =>
browserPreview.show(previewHost(event), bounds),
);
ipcMain.handle("desktop.browserPreview.hide", (event) => browserPreview.hide(previewHost(event)));
ipcMain.handle("desktop.browserPreview.navigate", (event, url: unknown) =>
browserPreview.navigate(previewHost(event), url),
);
ipcMain.handle("desktop.browserPreview.back", (event) => browserPreview.back(previewHost(event)));
ipcMain.handle("desktop.browserPreview.forward", (event) =>
browserPreview.forward(previewHost(event)),
);
ipcMain.handle("desktop.browserPreview.reload", (event) =>
browserPreview.reload(previewHost(event)),
);
ipcMain.handle("desktop.browserPreview.stop", (event) => browserPreview.stop(previewHost(event)));
ipcMain.handle("desktop.browserPreview.openDevTools", (event) =>
browserPreview.openDevTools(previewHost(event)),
);
ipcMain.handle("desktop.files.pickDirectory", async (event) => { ipcMain.handle("desktop.files.pickDirectory", async (event) => {
if (!fromMainWindow(event)) return null; if (!fromMainWindow(event)) return null;
const result = await dialog.showOpenDialog(windowFrom(event)!, { const result = await dialog.showOpenDialog(windowFrom(event)!, {

View File

@ -21,6 +21,22 @@ contextBridge.exposeInMainWorld("rakazoDesktop", {
download: () => ipcRenderer.invoke("desktop.update.download"), download: () => ipcRenderer.invoke("desktop.update.download"),
install: () => ipcRenderer.invoke("desktop.update.install"), install: () => ipcRenderer.invoke("desktop.update.install"),
}, },
browserPreview: {
state: () => ipcRenderer.invoke("desktop.browserPreview.state"),
show: (bounds) => ipcRenderer.invoke("desktop.browserPreview.show", bounds),
hide: () => ipcRenderer.invoke("desktop.browserPreview.hide"),
navigate: (url) => ipcRenderer.invoke("desktop.browserPreview.navigate", url),
back: () => ipcRenderer.invoke("desktop.browserPreview.back"),
forward: () => ipcRenderer.invoke("desktop.browserPreview.forward"),
reload: () => ipcRenderer.invoke("desktop.browserPreview.reload"),
stop: () => ipcRenderer.invoke("desktop.browserPreview.stop"),
openDevTools: () => ipcRenderer.invoke("desktop.browserPreview.openDevTools"),
onState: (listener) => {
const handler = (_event, state) => listener(state);
ipcRenderer.on("desktop.browserPreview.state", handler);
return () => ipcRenderer.off("desktop.browserPreview.state", handler);
},
},
oauth: { oauth: {
open: (url) => ipcRenderer.invoke("desktop.oauth.open", url), open: (url) => ipcRenderer.invoke("desktop.oauth.open", url),
cancel: (url) => ipcRenderer.invoke("desktop.oauth.cancel", url), cancel: (url) => ipcRenderer.invoke("desktop.oauth.cancel", url),

View File

@ -32,6 +32,7 @@ describe("desktop preload bridge", () => {
expect(globalName).toBe("rakazoDesktop"); expect(globalName).toBe("rakazoDesktop");
expect(bridge.platform).toBe("linux"); expect(bridge.platform).toBe("linux");
expect(Object.keys(bridge).sort()).toEqual([ expect(Object.keys(bridge).sort()).toEqual([
"browserPreview",
"files", "files",
"localSettings", "localSettings",
"oauth", "oauth",
@ -46,6 +47,18 @@ describe("desktop preload bridge", () => {
"toggleMaximize", "toggleMaximize",
]); ]);
expect(Object.keys(bridge.update).sort()).toEqual(["check", "download", "install", "state"]); expect(Object.keys(bridge.update).sort()).toEqual(["check", "download", "install", "state"]);
expect(Object.keys(bridge.browserPreview ?? {}).sort()).toEqual([
"back",
"forward",
"hide",
"navigate",
"onState",
"openDevTools",
"reload",
"show",
"state",
"stop",
]);
await bridge.files?.pickDirectory(); await bridge.files?.pickDirectory();
await bridge.oauth.open?.("https://provider.example.com/authorize"); await bridge.oauth.open?.("https://provider.example.com/authorize");
@ -58,6 +71,8 @@ describe("desktop preload bridge", () => {
await bridge.update.check(); await bridge.update.check();
await bridge.update.download(); await bridge.update.download();
await bridge.update.install(); await bridge.update.install();
await bridge.browserPreview?.navigate("http://localhost:5173/");
await bridge.browserPreview?.show({ x: 1, y: 2, width: 3, height: 4 });
expect(invoke.mock.calls.map(([channel]) => channel)).toEqual([ expect(invoke.mock.calls.map(([channel]) => channel)).toEqual([
"desktop.files.pickDirectory", "desktop.files.pickDirectory",
"desktop.oauth.open", "desktop.oauth.open",
@ -70,6 +85,8 @@ describe("desktop preload bridge", () => {
"desktop.update.check", "desktop.update.check",
"desktop.update.download", "desktop.update.download",
"desktop.update.install", "desktop.update.install",
"desktop.browserPreview.navigate",
"desktop.browserPreview.show",
]); ]);
}); });
@ -77,6 +94,7 @@ describe("desktop preload bridge", () => {
const { exposeInMainWorld } = runPreload("preload.cjs"); const { exposeInMainWorld } = runPreload("preload.cjs");
const [, bridge] = exposeInMainWorld.mock.calls[0] as [string, Record<string, unknown>]; const [, bridge] = exposeInMainWorld.mock.calls[0] as [string, Record<string, unknown>];
expect(Object.keys(bridge).sort()).toEqual([ expect(Object.keys(bridge).sort()).toEqual([
"browserPreview",
"files", "files",
"localSettings", "localSettings",
"oauth", "oauth",

View File

@ -28,7 +28,11 @@ export default function NewSpace() {
return; return;
} }
router.dismissAll(); router.dismissAll();
router.replace("/"); // A new project opens in the conversation with its own main agent.
const main = space.groups.find((group) => group.kind === "main");
router.replace(
main ? { pathname: "/group-thread", params: { groupId: main.id, name: main.name } } : "/",
);
} catch (reason) { } catch (reason) {
setError(reason instanceof Error ? reason.message : t("Could not create space")); setError(reason instanceof Error ? reason.message : t("Could not create space"));
setPending(false); setPending(false);

View File

@ -17,10 +17,12 @@ import {
buildComposerMentionOptions, buildComposerMentionOptions,
type ComposerMention, type ComposerMention,
cloudAgentHttpsUrl, cloudAgentHttpsUrl,
deriveWorkspaceFocus,
isApprovalAskBlock, isApprovalAskBlock,
isRunTerminalEvent, isRunTerminalEvent,
isSecretAskBlock, isSecretAskBlock,
latestAnswerableAskMessageId, latestAnswerableAskMessageId,
liveScreenRunId,
mentionChipKey, mentionChipKey,
projectMessageReactions, projectMessageReactions,
resolveComposerSendPlan, resolveComposerSendPlan,
@ -288,6 +290,8 @@ function Thread() {
? { botId } ? { botId }
: undefined; : undefined;
const [snap, setSnap] = useState<MobileSnapshot | null>(null); const [snap, setSnap] = useState<MobileSnapshot | null>(null);
/** Run that last pointed at the bot's screen, so the live screen is offered only during real screen work. */
const [screenRunId, setScreenRunId] = useState<string | null>(null);
const activeThreadId = useRef<string | undefined>(undefined); const activeThreadId = useRef<string | undefined>(undefined);
const [draft, setDraft] = useState(""); const [draft, setDraft] = useState("");
const [mentionQuery, setMentionQuery] = useState<string | null>(null); const [mentionQuery, setMentionQuery] = useState<string | null>(null);
@ -860,6 +864,7 @@ function Thread() {
} }
expandedHistoryThread.current = null; expandedHistoryThread.current = null;
historyEpoch.current += 1; historyEpoch.current += 1;
setScreenRunId(null);
const abort = new AbortController(); const abort = new AbortController();
void (async () => { void (async () => {
// Pending search jumps load the around-page separately; avoid replacing it with latest. // Pending search jumps load the around-page separately; avoid replacing it with latest.
@ -885,6 +890,8 @@ function Thread() {
(event) => { (event) => {
cursor = Math.max(cursor, event.seq ?? -1); cursor = Math.max(cursor, event.seq ?? -1);
retryMs = 250; retryMs = 250;
const screenRun = groupId ? null : liveScreenRunId(event);
if (screenRun) setScreenRunId(screenRun);
if ( if (
event.type === "thread.progress" || event.type === "thread.progress" ||
event.type === "agent.tool.called" || event.type === "agent.tool.called" ||
@ -1271,6 +1278,23 @@ function Thread() {
} }
const answerableAskMessageId = latestAnswerableAskMessageId(snap); const answerableAskMessageId = latestAnswerableAskMessageId(snap);
const screenFocus = useMemo(() => {
const computer = snap?.computer;
if (!snap || !computer || groupId) return null;
const focus = deriveWorkspaceFocus(
{
...snap,
computer: {
kind: computer.kind ?? "",
state: computer.state,
screenAvailable: computer.screenAvailable,
takeoverRequested: computer.takeoverRequested ?? false,
},
},
{ screenRunId },
);
return focus.pane === "screen" ? focus : null;
}, [groupId, screenRunId, snap]);
const runError = snap?.run?.status === "failed" ? (snap.run.error ?? null) : null; const runError = snap?.run?.status === "failed" ? (snap.run.error ?? null) : null;
const liveMessages = useMemo(() => [...visibleMessages].reverse(), [visibleMessages]); const liveMessages = useMemo(() => [...visibleMessages].reverse(), [visibleMessages]);
const messagesById = useMemo( const messagesById = useMemo(
@ -1668,6 +1692,43 @@ function Thread() {
) : null} ) : null}
</View> </View>
<View style={{ paddingBottom: keyboardVisible ? 12 : Math.max(insets.bottom + 12, 24) }}> <View style={{ paddingBottom: keyboardVisible ? 12 : Math.max(insets.bottom + 12, 24) }}>
{screenFocus && botId ? (
<Pressable
accessibilityRole="button"
testID="live-screen-pill"
onPress={() =>
router.push({
pathname: "/computer",
params: { botId, name: displayName ?? t("Bot") },
})
}
style={({ pressed }) => ({
alignSelf: "center",
marginTop: 10,
flexDirection: "row",
alignItems: "center",
gap: 8,
borderRadius: 999,
borderWidth: 1,
borderColor: native.fillPressed,
backgroundColor: pressed ? native.fillPressed : native.fill,
paddingHorizontal: 14,
paddingVertical: 8,
})}
>
<View
style={{
width: 8,
height: 8,
borderRadius: 4,
backgroundColor: screenFocus.takeover ? tokens.warning : tokens.success,
}}
/>
<Text style={{ color: tokens.foreground, fontSize: 14, fontWeight: "500" }}>
{screenFocus.takeover ? t("Needs takeover") : t("Open computer")}
</Text>
</Pressable>
) : null}
{replyTarget ? ( {replyTarget ? (
<View <View
style={{ style={{

View File

@ -1857,6 +1857,32 @@ describe("mobile thread event reduction", () => {
).toBe("Slack · Alex: Hello from the group"); ).toBe("Slack · Alex: Hello from the group");
}); });
it("reads a choice card as its question and answer", () => {
const card = {
kind: "choice" as const,
step: "goal" as const,
question: "What’s one thing you want to get done in the next few weeks?",
textInput: { placeholder: "Your goal" },
options: [{ id: "skip", letter: "", label: "Skip" }],
};
expect(blockText(mobileMessage("choice-1", [card]))).toBe(card.question);
expect(
blockText(
mobileMessage("choice-2", [{ ...card, answerId: "_text", answerText: "Run a 10K" }]),
),
).toBe(`${card.question}\nRun a 10K`);
expect(blockText(mobileMessage("choice-3", [{ ...card, answerId: "skip" }]))).toBe(
`${card.question}\nSkip`,
);
const note = {
kind: "intake_note" as const,
note: "done" as const,
nextCheckInAt: null,
text: "All set. I’m here whenever you need me.",
};
expect(blockText(mobileMessage("note-1", [note]))).toBe(note.text);
});
it("deduplicates durable messages and replaces matching transient subagent state", () => { it("deduplicates durable messages and replaces matching transient subagent state", () => {
const initial = snapshot([ const initial = snapshot([
mobileMessage("message-1", [{ kind: "text", text: "old" }]), mobileMessage("message-1", [{ kind: "text", text: "old" }]),

View File

@ -728,7 +728,7 @@ export type MobileBot = Pick<
| "modelId" | "modelId"
| "thinkingLevel" | "thinkingLevel"
> & > &
Partial<Pick<Bot, "parentBotId" | "spaceId">>; Partial<Pick<Bot, "parentBotId" | "spaceId" | "kind">>;
export type MobileBotSection = BotSection; export type MobileBotSection = BotSection;
@ -790,9 +790,11 @@ export type MobileSnapshot = {
activeRuns?: Array<{ id: string; botId?: string; status: string }>; activeRuns?: Array<{ id: string; botId?: string; status: string }>;
members?: MobileGroup["members"]; members?: MobileGroup["members"];
computer?: { computer?: {
kind?: string;
state: string; state: string;
controlHolder: string; controlHolder: string;
screenAvailable: boolean; screenAvailable: boolean;
takeoverRequested?: boolean;
mode: ComputerMode; mode: ComputerMode;
busyBotName: string | null; busyBotName: string | null;
}; };
@ -874,6 +876,12 @@ export function blockText(message: MobileMessage) {
if (block.kind === "child_bot") { if (block.kind === "child_bot") {
return `${block.status === "archived" ? "Archived" : block.status === "deleted" ? "Deleted" : "Bot"} ${block.name ?? ""}`; return `${block.status === "archived" ? "Archived" : block.status === "deleted" ? "Deleted" : "Bot"} ${block.name ?? ""}`;
} }
// Choice cards are answered on the web; here they read as the question and its answer.
if (block.kind === "choice") {
const answer =
block.answerText ?? block.options.find((option) => option.id === block.answerId)?.label;
return answer ? `${block.question}\n${answer}` : block.question;
}
if (block.kind === "chart") return `[chart: ${block.name ?? "chart"}]`; if (block.kind === "chart") return `[chart: ${block.name ?? "chart"}]`;
if (block.kind === "image") return `[image: ${block.name ?? "attachment"}]`; if (block.kind === "image") return `[image: ${block.name ?? "attachment"}]`;
if (block.kind === "file") { if (block.kind === "file") {
@ -896,6 +904,7 @@ type ThreadEvent = {
type: string; type: string;
seq?: number; seq?: number;
runId?: string; runId?: string;
createdAt?: string;
payload?: Record<string, unknown>; payload?: Record<string, unknown>;
}; };
@ -1074,7 +1083,7 @@ export function applyMobileThreadEvent(
messages: [...remaining, streaming], messages: [...remaining, streaming],
}; };
} }
if (event.type === "agent.tool.completed") { if (event.type === "agent.tool.completed" || event.type === "thread.computer") {
return { ...prev, cursor: event.seq ?? prev.cursor }; return { ...prev, cursor: event.seq ?? prev.cursor };
} }
if (event.type === "thread.subagent") { if (event.type === "thread.subagent") {

View File

@ -83,7 +83,7 @@ describe("display and warnings", () => {
expect(apiBaseWarning("http://127.0.0.1:3100")).toBeNull(); expect(apiBaseWarning("http://127.0.0.1:3100")).toBeNull();
expect(apiBaseWarning("http://192.168.1.20:3100")).toBeNull(); expect(apiBaseWarning("http://192.168.1.20:3100")).toBeNull();
expect(apiBaseWarning("http://100.64.0.1:3100")).toBeNull(); expect(apiBaseWarning("http://100.64.0.1:3100")).toBeNull();
expect(apiBaseWarning("http://100.119.57.55:3100")).toBeNull(); expect(apiBaseWarning("http://100.100.100.100:3100")).toBeNull();
expect(apiBaseWarning("http://100.127.255.255:3100")).toBeNull(); expect(apiBaseWarning("http://100.127.255.255:3100")).toBeNull();
expect(apiBaseWarning("http://app.example.com")).toMatch(/https/i); expect(apiBaseWarning("http://app.example.com")).toMatch(/https/i);
}); });

View File

@ -111,6 +111,29 @@ describe("spaceInboxItems", () => {
expect(items.map((item) => item.type)).toEqual(["bot", "group"]); expect(items.map((item) => item.type)).toEqual(["bot", "group"]);
}); });
it("lists a project's main conversation instead of its main bot", () => {
const main = {
id: "group-main",
name: "Chief",
kind: "main" as const,
preview: "",
pinned: false,
sectionId: null,
archivedAt: null,
unread: false,
updatedAt: "2026-01-01T00:00:00.000Z",
members: [],
};
const lead: MobileBot = { ...bot, id: "bot-lead", name: "Chief", kind: "main" };
const items = spaceInboxItems([space({ bots: [lead, bot], groups: [main] })]);
expect(items.map((item) => (item.type === "heading" ? item.title : item.type))).toEqual([
"Work",
"bot",
"group",
]);
expect(items.some((item) => item.type === "bot" && item.bot.id === lead.id)).toBe(false);
});
it("keeps empty spaces beside a populated space", () => { it("keeps empty spaces beside a populated space", () => {
const personal = space({ id: "personal", isDefault: true, bots: [bot] }); const personal = space({ id: "personal", isDefault: true, bots: [bot] });
expect(spaceInboxItems([personal, space()]).map((item) => item.type)).toEqual([ expect(spaceInboxItems([personal, space()]).map((item) => item.type)).toEqual([

View File

@ -32,7 +32,10 @@ export function canDeleteInboxSpace(
export function spaceInboxItems(spaces: InboxSpace[]): InboxSpaceItem[] { export function spaceInboxItems(spaces: InboxSpace[]): InboxSpaceItem[] {
return spaces.flatMap((space): InboxSpaceItem[] => { return spaces.flatMap((space): InboxSpaceItem[] => {
const chats = [ const chats = [
...space.bots.map((chat) => ({ type: "bot" as const, bot: chat, ...chat })), // A main bot is reached through its space's main conversation, not listed again.
...space.bots
.filter((chat) => chat.kind !== "main")
.map((chat) => ({ type: "bot" as const, bot: chat, ...chat })),
...space.groups.map((chat) => ({ type: "group" as const, group: chat, ...chat })), ...space.groups.map((chat) => ({ type: "group" as const, group: chat, ...chat })),
]; ];
const items: InboxSpaceItem[] = []; const items: InboxSpaceItem[] = [];

View File

@ -0,0 +1,104 @@
import { expect, test } from "@playwright/test";
import { activeBotId, captureScreenshot, completeOnboarding, rpc, signup } from "./helpers";
const SCREEN_URL = "https://screen.example/embed.html";
test("the bot's screen opens beside the chat while it works and steps back after", async ({
page,
}, testInfo) => {
await signup(page, `workspace-screen-${Date.now()}@rakazo.test`, "password12", "Screen Pane");
await completeOnboarding(page);
const botId = activeBotId(page);
await rpc(page, "computer/boot", { botId });
await page.route("https://screen.example/**", (route) =>
route.fulfill({
contentType: "text/html",
body: `<!doctype html><title>Desktop</title><body style="margin:0;background:#1d1d20;display:grid;place-items:center;height:100vh">
<input style="width:40%;padding:8px;font:16px system-ui" placeholder="Search">`,
}),
);
await page.route("**/rpc/computer/screenUrl", (route) =>
route.fulfill({
contentType: "application/json",
body: JSON.stringify({ json: { url: SCREEN_URL } }),
}),
);
const sidePanel = page.getByTestId("side-panel");
await expect(sidePanel).toHaveAttribute("data-panel", "closed");
await rpc(page, "threads/send", { botId, text: "use your screen and type workspace-demo" });
await expect(sidePanel).toHaveAttribute("data-panel", "computer");
await expect(sidePanel).toHaveAttribute("data-auto", "");
await expect(page.getByTestId("computer-preview").locator("iframe")).toHaveAttribute(
"src",
SCREEN_URL,
);
// The live screen gets more room than a settings panel.
expect((await sidePanel.boundingBox())!.width).toBeGreaterThan(400);
await captureScreenshot(page, testInfo, "workspace-screen-beside-chat");
// Once the work is done the conversation gets its full width back.
await expect(sidePanel).toHaveAttribute("data-panel", "closed", { timeout: 20_000 });
});
test("a report the bot presents opens beside the conversation", async ({ page }, testInfo) => {
await signup(page, `workspace-report-${Date.now()}@rakazo.test`, "password12", "Report Pane");
await completeOnboarding(page);
const composer = page.getByPlaceholder(/Message/);
await composer.fill("present the report");
await page.keyboard.press("Enter");
const pane = page.getByTestId("artifact-pane");
await expect(pane).toBeVisible({ timeout: 30_000 });
await expect(pane.getByRole("heading", { name: "report.md" })).toBeVisible();
await expect(pane.getByRole("heading", { name: "Report", exact: true })).toBeVisible();
await expect(pane.getByRole("cell", { name: "Research" })).toBeVisible();
await expect(composer).toBeVisible();
await captureScreenshot(page, testInfo, "workspace-report-beside-chat");
const downloadPromise = page.waitForEvent("download");
await pane.getByRole("button", { name: "Download report.md" }).click();
expect((await downloadPromise).suggestedFilename()).toBe("report.md");
await pane.getByRole("button", { name: "Close panel" }).click();
await expect(pane).toHaveCount(0);
// The file card in the chat opens the same pane again.
await page.getByRole("button", { name: "Preview report.md" }).click();
await expect(pane.getByRole("heading", { name: "Report", exact: true })).toBeVisible();
});
test("a question that scrolled away is one click back", async ({ page }, testInfo) => {
await page.setViewportSize({ width: 1280, height: 520 });
await signup(page, `workspace-ask-${Date.now()}@rakazo.test`, "password12", "Ask Jump");
await completeOnboarding(page);
const composer = page.getByPlaceholder(/Message/);
// Earlier work gives the thread enough history to scroll back through.
for (const task of ["sort the inbox", "draft the agenda", "book the room"]) {
await composer.fill(task);
await page.keyboard.press("Enter");
await expect(page.getByText(`done. i handled: ${task}`, { exact: true })).toBeVisible({
timeout: 30_000,
});
}
await composer.fill("ask me which city to use");
await page.keyboard.press("Enter");
const sendIt = page.getByRole("button", { name: "Send it" });
await expect(sendIt).toBeVisible({ timeout: 30_000 });
const jump = page.getByTestId("needs-you-jump");
await expect(jump).toHaveCount(0);
await page.getByTestId("transcript").hover();
await page.mouse.wheel(0, -20_000);
await expect(jump).toBeVisible();
await captureScreenshot(page, testInfo, "workspace-needs-you-jump");
await jump.click();
await expect(sendIt).toBeInViewport();
await expect(sendIt).toBeFocused();
await expect(jump).toHaveCount(0);
});

View File

@ -0,0 +1,53 @@
import { expect, test } from "@playwright/test";
import { activeBotId, captureScreenshot, completeOnboarding, rpc, signup } from "./helpers";
const SCREEN_URL = "https://screen.example/embed.html";
test("the bot's cursor glides to where it clicks on its live screen", async ({
page,
}, testInfo) => {
await signup(page, `agent-cursor-${Date.now()}@rakazo.test`, "password12", "Cursor Watcher");
await completeOnboarding(page);
const botId = activeBotId(page);
await rpc(page, "computer/boot", { botId });
// Stand in for the noVNC viewer: a 16:10 desktop with a field in the middle.
await page.route("https://screen.example/**", (route) =>
route.fulfill({
contentType: "text/html",
body: `<!doctype html><title>Desktop</title><body style="margin:0;background:#1d1d20;display:grid;place-items:center;height:100vh">
<input style="width:40%;padding:8px;font:16px system-ui" placeholder="Search">`,
}),
);
await page.route("**/rpc/computer/screenUrl", (route) =>
route.fulfill({
contentType: "application/json",
body: JSON.stringify({ json: { url: SCREEN_URL } }),
}),
);
await page.getByTitle("Agent computer").click();
const preview = page.getByTestId("computer-preview");
await expect(preview.locator("iframe")).toHaveAttribute("src", SCREEN_URL);
const cursor = preview.getByTestId("agent-cursor");
await expect(cursor).toHaveCSS("opacity", "0");
// The scripted runtime clicks the screen center (640, 400) and types.
await rpc(page, "threads/send", { botId, text: "use your screen and type cursor-demo" });
await expect(cursor).toHaveCSS("opacity", "1");
await expect(cursor).toHaveAttribute("data-action", /click|type/);
const layer = (await preview.getByTestId("agent-cursor-layer").boundingBox())!;
await expect
.poll(async () => {
const tip = (await cursor.boundingBox())!;
return [
Math.round(tip.x - layer.x - layer.width / 2),
Math.round(tip.y - layer.y - layer.height / 2),
];
})
.toEqual([0, 0]);
await captureScreenshot(page, testInfo, "agent-cursor-on-screen");
// It fades once the bot stops pointing, so it never lingers over the screen.
await expect(cursor).toHaveCSS("opacity", "0", { timeout: 10_000 });
});

View File

@ -27,6 +27,8 @@ test("agent-attached files appear as downloadable cards", async ({ page }, testI
test("agent-attached Markdown opens a rendered preview and can be downloaded", async ({ test("agent-attached Markdown opens a rendered preview and can be downloaded", async ({
page, page,
}, testInfo) => { }, testInfo) => {
// Narrow windows preview in a dialog; wide ones open the file beside the chat.
await page.setViewportSize({ width: 900, height: 800 });
const stamp = Date.now(); const stamp = Date.now();
await signup(page, `markdown-preview-${stamp}@rakazo.test`, "password12", "Markdown Preview"); await signup(page, `markdown-preview-${stamp}@rakazo.test`, "password12", "Markdown Preview");
await completeOnboarding(page); await completeOnboarding(page);

View File

@ -4,6 +4,10 @@ import { captureScreenshot } from "./helpers";
const fixture = "/e2e/fixtures/chart-links.html"; const fixture = "/e2e/fixtures/chart-links.html";
test("chart links cannot execute script when clicked or expanded", async ({ page }, testInfo) => { test("chart links cannot execute script when clicked or expanded", async ({ page }, testInfo) => {
test.setTimeout(120_000);
// ChartCanvas lazy-loads Observable Plot; pay the import cost once before the matrix below.
await page.goto(`${fixture}?${new URLSearchParams({ href: "#details", inspect: "0" })}`);
await expect(page.locator("svg circle").first()).toBeVisible({ timeout: 90_000 });
for (const inspect of ["0", "1"]) { for (const inspect of ["0", "1"]) {
for (const href of [ for (const href of [
'javascript:void(document.body.dataset.plotExecuted="yes")', 'javascript:void(document.body.dataset.plotExecuted="yes")',

View File

@ -16,11 +16,12 @@ test.use({ viewport: { width: 1280, height: 1000 } });
/** /**
* Texts in `locator` below WCAG AA against the surface they are painted on. With `inside`, every * Texts in `locator` below WCAG AA against the surface they are painted on. With `inside`, every
* visible text and typed value within it counts, except in disabled controls, which WCAG exempts. * visible text and typed value within it counts, except in disabled controls, which WCAG exempts,
* and in elements matching `skip`.
*/ */
function unreadableTexts(locator: Locator, inside: boolean) { function unreadableTexts(locator: Locator, inside: boolean, skip: string | null) {
return locator.evaluate( return locator.evaluate(
(root, { inside, min }) => { (root, { inside, skip, min }) => {
const context = document const context = document
.createElement("canvas") .createElement("canvas")
.getContext("2d", { willReadFrequently: true }); .getContext("2d", { willReadFrequently: true });
@ -81,7 +82,11 @@ function unreadableTexts(locator: Locator, inside: boolean) {
}; };
const elements = inside const elements = inside
? [root, ...root.querySelectorAll("*")].filter( ? [root, ...root.querySelectorAll("*")].filter(
(element) => ownText(element) && shown(element) && !element.closest(":disabled"), (element) =>
ownText(element) &&
shown(element) &&
!element.closest(":disabled") &&
!(skip && element.closest(skip)),
) )
: [root]; : [root];
return elements.flatMap((element) => { return elements.flatMap((element) => {
@ -90,14 +95,18 @@ function unreadableTexts(locator: Locator, inside: boolean) {
return ratio < min ? [`${ratio.toFixed(2)}:1 ${text.slice(0, 48)}`] : []; return ratio < min ? [`${ratio.toFixed(2)}:1 ${text.slice(0, 48)}`] : [];
}); });
}, },
{ inside, min: MIN_CONTRAST }, { inside, skip, min: MIN_CONTRAST },
); );
} }
async function expectReadable(locator: Locator, what: string, { inside = false } = {}) { async function expectReadable(
locator: Locator,
what: string,
{ inside = false, skip = null }: { inside?: boolean; skip?: string | null } = {},
) {
await expect(locator).toBeVisible(); await expect(locator).toBeVisible();
await expect await expect
.poll(() => unreadableTexts(locator, inside), { message: `${what} is readable` }) .poll(() => unreadableTexts(locator, inside, skip), { message: `${what} is readable` })
.toEqual([]); .toEqual([]);
} }
@ -213,6 +222,8 @@ test("shell surfaces stay readable in light and dark themes", async ({ page }, t
await composer.fill("Draft a note about owls"); await composer.fill("Draft a note about owls");
const sidebar = page.getByTestId("bots-sidebar"); const sidebar = page.getByTestId("bots-sidebar");
const sidePanel = page.getByTestId("side-panel"); const sidePanel = page.getByTestId("side-panel");
await expect(sidebar.locator("[data-main-conversation]")).toBeVisible();
await expect(sidebar.getByRole("button", { name: "Researcher, 1:1 conversation" })).toBeVisible();
await inEachTheme(page, async (theme) => { await inEachTheme(page, async (theme) => {
await captureScreenshot(page, testInfo, `shell-contrast-${theme}`); await captureScreenshot(page, testInfo, `shell-contrast-${theme}`);
@ -220,27 +231,9 @@ test("shell surfaces stay readable in light and dark themes", async ({ page }, t
await expectReadable(page.getByTestId("composer-bar"), `${theme}: the composer`, { await expectReadable(page.getByTestId("composer-bar"), `${theme}: the composer`, {
inside: true, inside: true,
}); });
await expectReadable( // The header carries the logo, which WCAG exempts; its create button is checked on its own.
sidebar.getByRole("button", { name: "Heute", exact: true }), await expectReadable(sidebar, `${theme}: the sidebar`, { inside: true, skip: ".app-drag" });
`${theme}: Heute`, await expectReadable(page.getByTestId("create-menu-trigger"), `${theme}: the create button`);
{ inside: true },
);
await expectReadable(
sidebar.locator("[data-main-conversation]"),
`${theme}: the main conversation row`,
{ inside: true },
);
await expectReadable(
sidebar.getByRole("button", { name: "Researcher, 1:1 conversation" }),
`${theme}: the selected bot row`,
{ inside: true },
);
await expectReadable(page.getByTestId("trading-mode-status"), `${theme}: trading status`, {
inside: true,
});
await expectReadable(page.getByTestId("user-menu-trigger"), `${theme}: the user menu`, {
inside: true,
});
}); });
await openNewBot(page); await openNewBot(page);
@ -259,4 +252,12 @@ test("shell surfaces stay readable in light and dark themes", async ({ page }, t
await captureScreenshot(page, testInfo, `shell-contrast-${theme}-settings`); await captureScreenshot(page, testInfo, `shell-contrast-${theme}-settings`);
await expectReadable(sidePanel, `${theme}: the bot settings form`, { inside: true }); await expectReadable(sidePanel, `${theme}: the bot settings form`, { inside: true });
}); });
await sidebar.getByRole("button", { name: "Today", exact: true }).click();
const heute = page.getByTestId("heute-brief");
await expect(heute).toBeVisible();
await inEachTheme(page, async (theme) => {
await captureScreenshot(page, testInfo, `shell-contrast-${theme}-heute`);
await expectReadable(heute, `${theme}: the Heute brief`, { inside: true });
});
}); });

View File

@ -1,98 +0,0 @@
import { expect, type Locator, test } from "@playwright/test";
import { completeOnboarding, rpc, signup, unterhaltungenOnly } from "./helpers";
async function selectProjectSpace(projekte: Locator, name: string) {
const row = projekte.locator('[data-sidebar-group^="project-space:"]').filter({ hasText: name });
await row.getByRole("button", { name: `Select ${name}` }).click();
}
async function projectSidebarStart(projekte: Locator, name: string) {
const row = projekte.locator('[data-sidebar-group^="project-space:"]').filter({ hasText: name });
const groupAttr = await row.getAttribute("data-sidebar-group");
const spaceId = groupAttr?.match(/^project-space:([^:]+)/)?.[1];
if (!spaceId) throw new Error(`missing project space id for ${name}`);
return projekte.getByTestId(`project-start-${spaceId}`);
}
test("empty project shows Starten instead of a fallback bot", async ({ page }) => {
const stamp = Date.now();
await signup(page, `empty-project-${stamp}@rakazo.test`, "password12", "Empty Project");
await completeOnboarding(page);
await page.goto("/app");
await page.waitForURL(/\/app\/[^/]+$/);
const projekte = page.locator(".sidebar-projekte");
await projekte.getByTestId("create-project-trigger").click();
const dialog = page.getByRole("dialog", { name: "New project" });
await dialog.getByLabel("Name").fill("Launch");
await dialog.getByRole("button", { name: "Create project", exact: true }).click();
await page.waitForURL(/\/app(?:\/)?$/);
await expect(projekte.getByRole("button", { name: /^Chief/ })).toHaveCount(0);
await expect(projekte.getByRole("button", { name: "Starten", exact: true })).toBeVisible();
await expect(page.getByTestId("project-start-main")).toBeVisible();
await page.getByTestId("project-start-main").click();
await expect(page.getByTestId("side-panel")).toHaveAttribute("data-panel", "create");
});
test("Starten in an onboarded project opens the group create flow", async ({ page }) => {
const stamp = Date.now();
await signup(page, `project-start-${stamp}@rakazo.test`, "password12", "Project Start");
await completeOnboarding(page);
const projekte = page.locator(".sidebar-projekte");
await projekte.getByTestId("create-project-trigger").click();
const dialog = page.getByRole("dialog", { name: "New project" });
await dialog.getByLabel("Name").fill("Website");
await dialog.getByRole("button", { name: "Create project", exact: true }).click();
await page.waitForURL(/\/app(?:\/)?$/);
await selectProjectSpace(projekte, "Website");
await page.waitForURL(/\/app(?:\/)?$/);
await expect(page.getByTestId("shell-root")).toHaveAttribute("data-ready", "true", {
timeout: 60_000,
});
await selectProjectSpace(projekte, "Website");
await page.waitForURL(/\/app(?:\/)?$/);
await expect(unterhaltungenOnly(page).getByRole("button", { name: /^Chief/ })).toHaveCount(1);
await expect(projekte.getByRole("button", { name: /^Chief/ })).toHaveCount(0);
const sidebarStart = await projectSidebarStart(projekte, "Website");
await expect(sidebarStart).toBeVisible({ timeout: 30_000 });
await sidebarStart.click();
await expect(page.getByTestId("side-panel")).toHaveAttribute("data-panel", "create-group", {
timeout: 30_000,
});
await expect(page.getByText("New group", { exact: true })).toBeVisible();
await expect(
page.getByTestId("side-panel").getByRole("button", { name: /^Chief/ }),
).toBeVisible();
});
test("archiving the only bot keeps Starten visible in the project sidebar", async ({ page }) => {
const stamp = Date.now();
await signup(page, `project-archive-${stamp}@rakazo.test`, "password12", "Project Archive");
await completeOnboarding(page);
const projekte = page.locator(".sidebar-projekte");
await projekte.getByTestId("create-project-trigger").click();
const dialog = page.getByRole("dialog", { name: "New project" });
await dialog.getByLabel("Name").fill("Side");
await dialog.getByRole("button", { name: "Create project", exact: true }).click();
await page.waitForURL(/\/app(?:\/)?$/);
const sideBot = await rpc<{ id: string }>(page, "bots/create", {
name: "Side bot",
title: "",
description: "",
notifyOnFinish: true,
computerMode: "team",
});
await rpc(page, "bots/archive", { botId: sideBot.id });
await page.goto("/app");
await page.waitForURL(/\/app(?:\/)?$/);
await expect(projekte.getByRole("button", { name: "Starten", exact: true })).toBeVisible();
await expect(page.getByTestId("project-start-main")).toBeVisible();
});

View File

@ -69,9 +69,10 @@ test("takeover, routine, plugins, and export are reachable", async ({ page }, te
}) })
.toBe("waiting_takeover"); .toBe("waiting_takeover");
await captureScreenshot(page, testInfo, "08-protected-input-request"); await captureScreenshot(page, testInfo, "08-protected-input-request");
await page.getByTitle("Agent computer").click(); // A takeover request brings the bot's screen beside the chat on its own.
const sidePanel = page.getByTestId("side-panel"); const sidePanel = page.getByTestId("side-panel");
await expect(sidePanel).toHaveCSS("width", "384px"); await expect(sidePanel).toHaveAttribute("data-panel", "computer");
await expect(sidePanel).toHaveAttribute("data-auto", "");
const [mainBox, panelBox] = await Promise.all([ const [mainBox, panelBox] = await Promise.all([
page.locator("main").boundingBox(), page.locator("main").boundingBox(),
sidePanel.boundingBox(), sidePanel.boundingBox(),

View File

@ -303,9 +303,11 @@ test("create group from + and see two bots in one transcript", async ({ page },
const groupMarkdown = page.getByRole("button", { name: "Preview group-preview.md" }); const groupMarkdown = page.getByRole("button", { name: "Preview group-preview.md" });
await expect(groupMarkdown).toBeVisible({ timeout: 30_000 }); await expect(groupMarkdown).toBeVisible({ timeout: 30_000 });
await groupMarkdown.click(); await groupMarkdown.click();
const markdownDialog = page.getByRole("dialog", { name: "group-preview.md" }); // Wide windows open the file beside the group conversation.
await expect(markdownDialog.getByRole("heading", { name: "Group artifact" })).toBeVisible(); const markdownPane = page.getByTestId("artifact-pane");
await markdownDialog.getByRole("button", { name: "Close preview" }).click(); await expect(markdownPane.getByRole("heading", { name: "Group artifact" })).toBeVisible();
await markdownPane.getByRole("button", { name: "Close panel" }).click();
await expect(markdownPane).toHaveCount(0);
await page.setViewportSize({ width: 390, height: 844 }); await page.setViewportSize({ width: 390, height: 844 });
await expect(page.getByRole("button", { name: "Open navigation" })).toBeVisible(); await expect(page.getByRole("button", { name: "Open navigation" })).toBeVisible();

View File

@ -88,8 +88,9 @@ async function visible(locator: Locator, timeout: number) {
} }
/** /**
* Walks first-run onboarding, which lands in the main conversation. Unless `mainConversation` * Walks the first-run wizard, which lands in the main conversation with the main bot's first
* is set, it then opens the main bot's own thread, where most specs start. * onboarding question pending (the composer works regardless). Unless `mainConversation` is
* set, it then opens the main bot's own thread, where most specs start.
*/ */
export async function completeOnboarding( export async function completeOnboarding(
page: Page, page: Page,
@ -101,13 +102,12 @@ export async function completeOnboarding(
if (page.url().includes("/onboarding")) { if (page.url().includes("/onboarding")) {
await expect(page.locator('[data-rakazo-surface="onboarding"]')).toHaveAttribute( await expect(page.locator('[data-rakazo-surface="onboarding"]')).toHaveAttribute(
"data-onboarding-step", "data-onboarding-step",
/^(emoji|preferences|language|voice|integrations|scan|bot)$/, /^(emoji|language|voice|integrations|scan|bot)$/,
{ timeout: 30_000 }, { timeout: 30_000 },
); );
} }
const design = page.getByRole("heading", { name: /Design your Oo/i }); const design = page.getByRole("heading", { name: /Design your Oo/i });
const preferences = page.getByRole("heading", { name: /Tell us about yourself/i });
const language = page.getByRole("heading", { name: /Choose your language/i }); const language = page.getByRole("heading", { name: /Choose your language/i });
const voice = page.getByRole("heading", { name: /Pick a voice/i }); const voice = page.getByRole("heading", { name: /Pick a voice/i });
const integrations = page.getByRole("heading", { name: "Server integrations", exact: true }); const integrations = page.getByRole("heading", { name: "Server integrations", exact: true });
@ -119,8 +119,6 @@ export async function completeOnboarding(
await expect(entry.first()).toBeVisible({ timeout: 20_000 }); await expect(entry.first()).toBeVisible({ timeout: 20_000 });
if (await design.isVisible()) { if (await design.isVisible()) {
await page.getByRole("button", { name: "Continue", exact: true }).click();
await expect(preferences).toBeVisible();
await page.getByRole("button", { name: "Continue", exact: true }).click(); await page.getByRole("button", { name: "Continue", exact: true }).click();
await expect(language).toBeVisible(); await expect(language).toBeVisible();
await page.getByRole("button", { name: /Skip/i }).click(); await page.getByRole("button", { name: /Skip/i }).click();
@ -133,8 +131,15 @@ export async function completeOnboarding(
await page.getByRole("button", { name: "Skip", exact: true }).click(); await page.getByRole("button", { name: "Skip", exact: true }).click();
} }
// Scan step auto-advances to the main bot, then onboarding lands in the main conversation. // The scan step, when shown, either asks first (skip it) or advances by itself. Onboarding
await page.waitForURL(/\/app\/g\/[^/]+$/, { timeout: 30_000 }); // then lands in the main conversation.
const skipScan = page
.locator('[data-onboarding-step="scan"]')
.getByRole("button", { name: "Skip", exact: true });
await expect(async () => {
if (await skipScan.isVisible()) await skipScan.click();
expect(page.url()).toMatch(/\/app\/g\/[^/]+$/);
}).toPass({ timeout: 30_000 });
await expect(chief).toBeVisible({ timeout: 15_000 }); await expect(chief).toBeVisible({ timeout: 15_000 });
await waitForChatComposer(page); await waitForChatComposer(page);
@ -242,6 +247,7 @@ export async function openUserSettings(
| "memory" | "memory"
| "profile" | "profile"
| "voice" | "voice"
| "notifications"
| "usage" | "usage"
| "computer" | "computer"
| "updates", | "updates",

View File

@ -1,20 +1,136 @@
import type { Page } from "@playwright/test";
import { expect, test } from "@playwright/test"; import { expect, test } from "@playwright/test";
import { captureScreenshot, completeOnboarding, signup } from "./helpers"; import type { CoachRhythm, CoachToday, Goal, Habit, Routine } from "@rakazo/contracts";
import { captureScreenshot, completeOnboarding, openMainBotThread, rpc, signup } from "./helpers";
/** The nav button reads "Today" or "Today, N need you". */
function openHeute(page: Page) {
return page.getByRole("button", { name: /^Today(,|$)/ }).click();
}
function shiftDate(date: string, days: number) {
return new Date(Date.parse(`${date}T00:00:00Z`) + days * 86_400_000).toISOString().slice(0, 10);
}
test("Heute shows native brief instead of Mission Control iframe", async ({ page }, testInfo) => { test("Heute shows native brief instead of Mission Control iframe", async ({ page }, testInfo) => {
const stamp = Date.now(); const stamp = Date.now();
await signup(page, `heute-${stamp}@rakazo.test`, "password12", "Heute"); await signup(page, `heute-${stamp}@rakazo.test`, "password12", "Heute");
await completeOnboarding(page); await completeOnboarding(page);
await page.getByRole("button", { name: "Heute", exact: true }).click(); await openHeute(page);
await expect(page).toHaveURL(/\/app\/[^/]+$/); await expect(page).toHaveURL(/\/app\/[^/]+$/);
const brief = page.getByTestId("heute-brief"); const brief = page.getByTestId("heute-brief");
await expect(brief).toBeVisible(); await expect(brief).toBeVisible();
await expect(page.locator('iframe[src="http://127.0.0.1:3088/"]')).toHaveCount(0); await expect(page.locator('iframe[src="http://127.0.0.1:3088/"]')).toHaveCount(0);
await expect(page.getByText("evOo Mission Control")).toHaveCount(0); await expect(page.getByText("evOo Mission Control")).toHaveCount(0);
await expect(page.getByTestId("heute-mission-control-link")).toHaveCount(0);
await expect(page.getByTestId("heute-status")).toBeVisible(); await expect(page.getByTestId("heute-status")).toBeVisible();
await expect(page.getByTestId("heute-mission-control-link")).toBeVisible(); // Nothing tracked yet: no coach sections.
await expect(page.getByTestId("heute-coach-brief")).toHaveCount(0);
await expect(page.getByTestId("heute-habits")).toHaveCount(0);
await captureScreenshot(page, testInfo, "heute-native-brief"); await captureScreenshot(page, testInfo, "heute-native-brief");
}); });
test("Heute shows the coach's brief, habits and goals, checks habits off and sets check-ins", async ({
page,
}, testInfo) => {
const stamp = Date.now();
await signup(page, `heute-coach-${stamp}@rakazo.test`, "password12", "Heute Coach");
await completeOnboarding(page);
// The main bot writes today's brief through its tool.
const composer = page.getByRole("combobox", { name: "Message" });
await composer.fill("write today's brief: Two calm hours on the beta, then a walk.");
await page.keyboard.press("Enter");
await expect(page.getByText("today's brief is ready.").first()).toBeVisible({ timeout: 30_000 });
const { date } = await rpc<CoachToday>(page, "coach/today", {});
const goal = await rpc<Goal>(page, "coach/goals/create", {
title: "Ship the beta",
targetDate: shiftDate(date, 30),
});
const habit = await rpc<Habit>(page, "coach/habits/create", { title: "Walk 20 minutes" });
for (const day of [1, 2]) {
await rpc(page, "coach/checkIns/log", {
kind: "habit",
habitId: habit.id,
date: shiftDate(date, -day),
done: true,
});
}
await openHeute(page);
await expect(page.getByTestId("heute-coach-brief")).toHaveText(
"Two calm hours on the beta, then a walk.",
);
await expect(page.getByTestId(`heute-goal-${goal.id}`)).toContainText("Ship the beta");
const habitRow = page.getByTestId(`heute-habit-${habit.id}`);
await expect(habitRow).toContainText("Walk 20 minutes");
await expect(page.getByTestId(`heute-habit-streak-${habit.id}`)).toHaveText("2 days");
const checkbox = habitRow.getByRole("checkbox");
await expect(checkbox).not.toBeChecked();
const logged = page.waitForResponse(
(response) => response.url().includes("/rpc/coach/checkIns/log") && response.ok(),
);
await checkbox.click();
await logged;
await expect(checkbox).toBeChecked();
await expect(page.getByTestId(`heute-habit-streak-${habit.id}`)).toHaveText("3 days");
await captureScreenshot(page, testInfo, "heute-coach");
await page.reload();
await openHeute(page);
await expect(page.getByTestId(`heute-habit-${habit.id}`).getByRole("checkbox")).toBeChecked();
// Check-in times live behind one compact control.
await page.getByTestId("heute-rhythm").click();
const panel = page.getByTestId("heute-rhythm-panel");
await expect(panel).toBeVisible();
const morning = panel.getByTestId("heute-rhythm-morning");
await expect(morning.getByRole("switch")).not.toBeChecked();
const enabled = page.waitForResponse(
(response) => response.url().includes("/rpc/coach/rhythm/set") && response.ok(),
);
await morning.getByRole("switch").click();
await enabled;
const time = morning.locator('input[type="time"]');
await expect(time).toHaveValue("08:00");
const moved = page.waitForResponse(
(response) => response.url().includes("/rpc/coach/rhythm/set") && response.ok(),
);
await time.fill("07:30");
await moved;
await expect(time).toHaveValue("07:30");
const weekly = panel.getByTestId("heute-rhythm-weekly");
const weeklyOn = page.waitForResponse(
(response) => response.url().includes("/rpc/coach/rhythm/set") && response.ok(),
);
await weekly.getByRole("switch").click();
await weeklyOn;
await expect(weekly.getByRole("combobox")).toHaveValue("0");
await captureScreenshot(page, testInfo, "heute-rhythm");
expect(await rpc<CoachRhythm>(page, "coach/rhythm/get", {})).toEqual({
morning: "07:30",
evening: null,
weekly: { day: 0, time: "18:00" },
});
const main = await rpc<{ id: string }>(page, "bots/ensureMain", {});
const routines = await rpc<Routine[]>(page, "routines/list", { botId: main.id });
expect(routines.map(({ name, coachKey }) => ({ name, coachKey }))).toEqual(
expect.arrayContaining([
{ name: "Morning brief", coachKey: "morning" },
{ name: "Weekly review", coachKey: "weekly" },
]),
);
expect(routines).toHaveLength(2);
// The main bot's routine list names the check-ins by their key, in the UI language.
await openMainBotThread(page);
await page.getByTitle("Agent computer").click();
await expect(page.getByText("Morning brief", { exact: true })).toBeVisible();
await expect(page.getByText("Weekly review", { exact: true })).toBeVisible();
});

View File

@ -0,0 +1,159 @@
import { expect, test } from "@playwright/test";
import { captureScreenshot, completeOnboarding, signup } from "./helpers";
test("the in-app browser previews a page and follows it back to the browser", async ({
page,
}, testInfo) => {
await signup(page, `in-app-browser-${Date.now()}@rakazo.test`, "password12", "Previewer");
await completeOnboarding(page);
// Context-wide so the "Open in browser" tab is served too.
await page.context().route("https://preview.example/**", (route) =>
route.fulfill({
contentType: "text/html",
body: `<!doctype html><title>Preview</title><body style="font:16px system-ui;padding:24px">
<h1>Live preview</h1><p>Rendered inside the app.</p>`,
}),
);
await page.getByRole("button", { name: "Browser" }).click();
const browser = page.getByTestId("in-app-browser");
const address = browser.getByRole("textbox", { name: "Address" });
await expect(address).toBeFocused();
await address.fill("not an address");
await address.press("Enter");
await expect(address).toHaveAttribute("aria-invalid", "true");
await address.fill("preview.example/app");
await address.press("Enter");
await expect(address).toHaveValue("https://preview.example/app");
const frame = browser.getByTestId("in-app-browser-frame");
await expect(frame).toHaveAttribute("src", "https://preview.example/app");
await expect(
page.frameLocator('[data-testid="in-app-browser-frame"]').getByRole("heading"),
).toHaveText("Live preview");
await captureScreenshot(page, testInfo, "in-app-browser");
const popup = page.waitForEvent("popup");
await browser.getByRole("button", { name: "Open in browser" }).click();
expect((await popup).url()).toBe("https://preview.example/app");
await browser.getByRole("button", { name: "Close panel" }).click();
await expect(browser).toHaveCount(0);
// The last address comes back when the browser reopens.
await page.getByRole("button", { name: "Browser" }).click();
await expect(page.getByTestId("in-app-browser-frame")).toHaveAttribute(
"src",
"https://preview.example/app",
);
});
test("on desktop the page is a native view glued to the panel", async ({ page }) => {
await page.addInitScript(() => {
const calls: Array<[string, unknown?]> = [];
let listener: ((state: unknown) => void) | undefined;
let state = {
url: "",
title: "",
loading: false,
canGoBack: false,
canGoForward: false,
error: null as string | null,
};
const record = (name: string) => async (value?: unknown) => {
calls.push([name, value]);
};
Object.assign(window, { __browserCalls: calls });
Object.defineProperty(window, "rakazoDesktop", {
value: {
platform: "linux",
window: {
close: async () => {},
minimize: async () => {},
toggleMaximize: async () => {},
state: async () => ({ minimized: false, maximized: false, fullScreen: false }),
},
oauth: { onCallback: () => () => {} },
update: {
state: async () => ({ phase: "unsupported", currentVersion: "0.0.0" }),
check: async () => ({ phase: "unsupported", currentVersion: "0.0.0" }),
download: async () => ({ phase: "unsupported", currentVersion: "0.0.0" }),
install: async () => ({ phase: "unsupported", currentVersion: "0.0.0" }),
},
browserPreview: {
state: async () => state,
onState: (next: (value: unknown) => void) => {
listener = next;
return () => {
listener = undefined;
};
},
navigate: async (url: string) => {
calls.push(["navigate", url]);
state = { ...state, url, title: "Dev server", loading: false, canGoBack: true };
listener?.(state);
},
show: record("show"),
hide: record("hide"),
back: record("back"),
forward: record("forward"),
reload: record("reload"),
stop: record("stop"),
openDevTools: record("openDevTools"),
},
},
});
});
await signup(page, `native-browser-${Date.now()}@rakazo.test`, "password12", "Desktop");
await completeOnboarding(page);
const calls = () =>
page.evaluate(
() => (window as unknown as { __browserCalls: Array<[string, unknown?]> }).__browserCalls,
);
await page.getByRole("button", { name: "Browser" }).click();
const browser = page.getByTestId("in-app-browser");
await browser.getByRole("textbox", { name: "Address" }).fill("localhost:5173");
await browser.getByRole("textbox", { name: "Address" }).press("Enter");
await expect.poll(calls).toContainEqual(["navigate", "http://localhost:5173/"]);
await expect(browser.getByRole("button", { name: "Back" })).toBeEnabled();
await expect(browser.getByRole("button", { name: "Forward" })).toBeDisabled();
await expect(browser.getByTestId("in-app-browser-frame")).toHaveCount(0);
// The reserved page area and the native view's bounds stay identical.
const viewport = browser.getByTestId("in-app-browser-viewport");
await expect
.poll(async () => {
const box = (await viewport.boundingBox())!;
const shown = (await calls()).filter(([name]) => name === "show").at(-1)?.[1];
return (
JSON.stringify(shown) ===
JSON.stringify({
x: Math.round(box.x),
y: Math.round(box.y),
width: Math.round(box.width),
height: Math.round(box.height),
})
);
})
.toBe(true);
await browser.getByRole("button", { name: "Back" }).click();
await browser.getByRole("button", { name: "Reload" }).click();
await browser.getByRole("button", { name: "Developer tools" }).click();
await expect
.poll(async () => (await calls()).map(([name]) => name))
.toEqual(expect.arrayContaining(["back", "reload", "openDevTools"]));
// App menus float above the page, so the native view steps aside while one is open.
const before = (await calls()).length;
await page.getByTestId("create-menu-trigger").click();
await expect
.poll(async () => (await calls()).slice(before).map(([name]) => name))
.toContain("hide");
await page.keyboard.press("Escape");
await expect.poll(async () => (await calls()).at(-1)?.[0]).toBe("show");
await browser.getByRole("button", { name: "Close panel" }).click();
await expect.poll(async () => (await calls()).at(-1)?.[0]).toBe("hide");
});

View File

@ -39,6 +39,75 @@ test("answers a specialist's question from the main conversation", async ({ page
await expect(page.getByText("Answered: Paris", { exact: true })).toBeVisible({ timeout: 30_000 }); await expect(page.getByText("Answered: Paris", { exact: true })).toBeVisible({ timeout: 30_000 });
}); });
test("answers a question from another space in the main conversation", async ({
page,
}, testInfo) => {
const stamp = Date.now();
await signup(page, `inbox-cross-${stamp}@rakazo.test`, "password12", "Inbox Cross");
await page.waitForURL(/\/(onboarding|app)/, { timeout: 30_000 });
const helper = await createBot(page, "Helper");
const side = await rpc<{ id: string }>(page, "spaces/create", { name: "Side project" });
const scout = await rpcInSpace<{ id: string }>(page, side.id, "bots/create", {
name: "Scout",
title: "",
description: "",
notifyOnFinish: true,
computerMode: "team",
});
await rpcInSpace(page, side.id, "threads/send", {
botId: scout.id,
text: "ask me which city to use",
});
const runInSide = async () =>
(
await rpcInSpace<{ run?: { status: string } | null }>(page, side.id, "threads/get", {
botId: scout.id,
})
).run ?? null;
await expect
.poll(async () => (await runInSide())?.status ?? null, { timeout: 60_000 })
.toBe("waiting_input");
await openMainConversation(page, helper.id);
const inbox = page.getByRole("region", { name: "Needs you" });
const ask = inbox.locator("[data-kind='ask']");
await expect(ask).toContainText("Scout", { timeout: 30_000 });
await expect(ask.getByTestId("main-inbox-space")).toHaveText("· Side project");
await captureScreenshot(page, testInfo, "inbox-cross-space");
await ask.getByRole("button", { name: "Edit first" }).click();
await ask.getByRole("textbox", { name: "Answer" }).fill("Paris");
await ask.getByRole("button", { name: "Send answer" }).click();
await expect(ask).toHaveCount(0);
await expect.poll(runInSide, { timeout: 60_000 }).toBeNull();
// The reply arrives as unread from the other space and opens there.
const reply = inbox.locator("[data-kind='unread']").filter({ hasText: "Scout" });
await expect(reply).toContainText("Paris", { timeout: 45_000 });
await expect(reply.getByTestId("main-inbox-space")).toHaveText("· Side project");
await reply.getByRole("button").click();
await page.waitForURL(new RegExp(`/app/${scout.id}$`));
await expect(page.getByText("Answered: Paris", { exact: true })).toBeVisible({ timeout: 30_000 });
expect(await page.evaluate(() => window.localStorage.getItem("rakazo:space-id"))).toBe(side.id);
});
async function rpcInSpace<T = unknown>(
page: Page,
spaceId: string,
procedure: string,
body: unknown,
): Promise<T> {
const response = await page.request.post(`/rpc/${procedure}`, {
data: { json: body },
headers: { "x-rakazo-space-id": spaceId },
});
const parsed = (await response.json()) as { json?: T; error?: { message?: string } };
if (!response.ok() || parsed.error) {
throw new Error(`${procedure} ${response.status()}: ${parsed.error?.message ?? "failed"}`);
}
return parsed.json as T;
}
async function createBot(page: Page, name: string) { async function createBot(page: Page, name: string) {
return rpc<{ id: string }>(page, "bots/create", { return rpc<{ id: string }>(page, "bots/create", {
name, name,

View File

@ -114,6 +114,36 @@ test("memory and skills are readable and editable in the app", async ({ page },
await expect(botDocEditor).toHaveValue(new RegExp(botMarker)); await expect(botDocEditor).toHaveValue(new RegExp(botMarker));
await botMemory.getByRole("button", { name: "Cancel", exact: true }).click(); await botMemory.getByRole("button", { name: "Cancel", exact: true }).click();
// Remembering appends a dated note and keeps the edit above.
await rpc(page, "threads/send", {
botId: activeBotId(page),
text: `remember that the tester ${stamp} prefers tea`,
});
await expect
.poll(
async () =>
(
await rpc<MemoryDocument[]>(page, "memory/list", {
botId: activeBotId(page),
scope: "bot",
})
).find((doc) => doc.path === "MEMORY.md")?.content,
{ timeout: 60_000 },
)
.toMatch(
new RegExp(
`^# Chief\\n\\n${botMarker}\\n\\n- \\d{4}-\\d{2}-\\d{2}: .*tester ${stamp} prefers tea\\n$`,
),
);
await expect
.poll(
async () =>
(await rpc<{ run?: unknown }>(page, "threads/get", { botId: activeBotId(page) })).run ??
null,
{ timeout: 60_000 },
)
.toBeNull();
// Skills: create one through the editor, reopen it, edit, then delete it. // Skills: create one through the editor, reopen it, edit, then delete it.
// Builtin catalog is currently empty; user skills still cover create/edit/delete. // Builtin catalog is currently empty; user skills still cover create/edit/delete.
await knowledge.getByRole("tab", { name: "Skills", exact: true }).click(); await knowledge.getByRole("tab", { name: "Skills", exact: true }).click();

View File

@ -75,7 +75,11 @@ test("local settings open and save integrations without an app session", async (
}); });
test("ordinary browsers cannot use local settings", async ({ page }) => { test("ordinary browsers cannot use local settings", async ({ page }) => {
await page.goto("/desktop-settings"); await expect(async () => {
await expect(page.getByRole("alert")).toContainText("Could not open local settings"); await page.goto("/desktop-settings", { waitUntil: "domcontentloaded" });
await expect(page.getByRole("alert")).toContainText("Could not open local settings", {
timeout: 5_000,
});
}).toPass({ timeout: 45_000 });
await expect(page.getByRole("button", { name: "Models", exact: true })).toBeHidden(); await expect(page.getByRole("button", { name: "Models", exact: true })).toBeHidden();
}); });

View File

@ -19,7 +19,10 @@ test("onboarding lands in the main conversation, its lead answers, and addressed
const homeSpaceId = await page.evaluate(() => window.localStorage.getItem("rakazo:space-id")); const homeSpaceId = await page.evaluate(() => window.localStorage.getItem("rakazo:space-id"));
const chief = await rpc<{ id: string }>(page, "bots/ensureMain", {}); const chief = await rpc<{ id: string }>(page, "bots/ensureMain", {});
const sidebar = page.locator("aside").first(); const sidebar = page.locator("aside").first();
const mainEntry = sidebar.getByRole("button", { name: "Chief, main conversation" }); // Projects list their own lead's main conversation too; this is the pinned home one.
const mainEntry = sidebar
.getByRole("button", { name: "Chief, main conversation" })
.and(sidebar.locator("[data-main-conversation]"));
await expect(mainEntry).toBeVisible(); await expect(mainEntry).toBeVisible();
// The main bot is reached through the main conversation, not listed again. // The main bot is reached through the main conversation, not listed again.
await expect(sidebar.locator(`[data-roster-bot-id="${chief.id}"]`)).toHaveCount(0); await expect(sidebar.locator(`[data-roster-bot-id="${chief.id}"]`)).toHaveCount(0);
@ -78,7 +81,8 @@ test("onboarding lands in the main conversation, its lead answers, and addressed
const dialog = page.getByRole("dialog", { name: "New project" }); const dialog = page.getByRole("dialog", { name: "New project" });
await dialog.getByLabel("Name").fill("Launch"); await dialog.getByLabel("Name").fill("Launch");
await dialog.getByRole("button", { name: "Create project", exact: true }).click(); await dialog.getByRole("button", { name: "Create project", exact: true }).click();
await page.waitForURL(/\/app(?:\/)?$/); // The project opens its own main conversation.
await page.waitForURL((url) => /\/app\/g\/[^/]+$/.test(url.pathname) && url.href !== mainUrl);
await expect await expect
.poll(() => page.evaluate(() => window.localStorage.getItem("rakazo:space-id"))) .poll(() => page.evaluate(() => window.localStorage.getItem("rakazo:space-id")))
.not.toBe(homeSpaceId); .not.toBe(homeSpaceId);

View File

@ -4,7 +4,8 @@ import { captureScreenshot, completeOnboarding, signup, waitForChatComposer } fr
/** Submit from the Send control so an in-flight prior send cannot swallow Enter. */ /** Submit from the Send control so an in-flight prior send cannot swallow Enter. */
async function sendComposerMessage(page: Page, composer: Locator, text: string) { async function sendComposerMessage(page: Page, composer: Locator, text: string) {
await composer.fill(text); await composer.fill(text);
const send = page.getByRole("button", { name: "Send", exact: true }); // An open onboarding card in the transcript has its own Send.
const send = page.getByTestId("composer-bar").getByRole("button", { name: "Send", exact: true });
await expect(send).toBeEnabled(); await expect(send).toBeEnabled();
await send.click(); await send.click();
await expect(composer).toHaveValue(""); await expect(composer).toHaveValue("");
@ -72,14 +73,25 @@ test("message hover shows beside-bubble actions; reply links to parent", async (
const transcript = page.getByTestId("transcript"); const transcript = page.getByTestId("transcript");
// Empty start: no Fresh-start greeting. Answer the focus card so a plain // Empty start: no Fresh-start greeting. Answer the focus card, then ask something long: a bot
// bot text bubble exists for hover layout checks. // row's rail sits at the row's end, so the measured reply must wrap and fill its row.
await expect(page.getByText("What do you want me on first?", { exact: true })).toBeVisible({ await expect(
page.getByTestId("transcript").getByText("What do you want me on first?", { exact: true }),
).toBeVisible({
timeout: 20_000, timeout: 20_000,
}); });
await page.getByTestId("focus-choice-day").click(); await page.getByTestId("focus-choice-day").click();
const botText = page.getByText(/Got it\./); await expect(page.locator('[data-testid="choice-card"][data-step="goal"]')).toBeVisible({
await expect(botText).toBeVisible({ timeout: 20_000 }); timeout: 20_000,
});
const composer = page.getByRole("combobox", { name: /^Message/ });
await sendComposerMessage(
page,
composer,
`hover-bot-${stamp} ${"please look into this ".repeat(7)}`,
);
const botText = page.getByText(/i handled: hover-bot-/);
await expect(transcript.getByText(/i handled: hover-bot-/)).toBeVisible({ timeout: 20_000 });
const botRow = transcript.locator(`[data-message-id]`).filter({ has: botText }).first(); const botRow = transcript.locator(`[data-message-id]`).filter({ has: botText }).first();
await expect(botRow).toBeVisible(); await expect(botRow).toBeVisible();
await expectRailAtRest(page, botRow); await expectRailAtRest(page, botRow);
@ -121,7 +133,6 @@ test("message hover shows beside-bubble actions; reply links to parent", async (
const parentText = `hover-parent-${stamp}`; const parentText = `hover-parent-${stamp}`;
const replyText = `hover-reply-${stamp}`; const replyText = `hover-reply-${stamp}`;
const composer = page.getByRole("combobox", { name: /^Message/ });
await expect(composer).toBeVisible(); await expect(composer).toBeVisible();
await sendComposerMessage(page, composer, parentText); await sendComposerMessage(page, composer, parentText);
@ -387,12 +398,19 @@ test.describe("touch message actions", () => {
expect( expect(
await page.evaluate(() => matchMedia("(hover: hover) and (pointer: fine)").matches), await page.evaluate(() => matchMedia("(hover: hover) and (pointer: fine)").matches),
).toBe(false); ).toBe(false);
await expect(page.getByText("What do you want me on first?", { exact: true })).toBeVisible({ await expect(
page.getByTestId("transcript").getByText("What do you want me on first?", { exact: true }),
).toBeVisible({
timeout: 20_000, timeout: 20_000,
}); });
await page.getByTestId("focus-choice-day").click(); await page.getByTestId("focus-choice-day").click();
const botText = page.getByText(/Got it\./); // Onboarding writes no text of its own, so ask for a plain reply to act on.
await expect(botText).toBeVisible({ timeout: 20_000 }); const composer = page.getByRole("combobox", { name: /^Message/ });
await sendComposerMessage(page, composer, `touch-bot-${Date.now()}`);
const botText = page.getByText(/i handled: touch-bot-/);
await expect(page.getByTestId("transcript").getByText(/i handled: touch-bot-/)).toBeVisible({
timeout: 20_000,
});
const row = page const row = page
.getByTestId("transcript") .getByTestId("transcript")
.locator("[data-message-id]") .locator("[data-message-id]")

View File

@ -42,7 +42,12 @@ test("create opens form, then empty chat; picker lists bots; sidebar collapses",
await page.waitForURL(/\/app\/[^/]+$/); await page.waitForURL(/\/app\/[^/]+$/);
await expect(page.getByRole("combobox", { name: "Message" })).toBeVisible(); await expect(page.getByRole("combobox", { name: "Message" })).toBeVisible();
await expect(page.getByTestId("side-panel")).toHaveAttribute("data-panel", "closed"); await expect(page.getByTestId("side-panel")).toHaveAttribute("data-panel", "closed");
await expect(page.getByText("What do you want me on first?", { exact: true })).toHaveCount(0); await expect(
page.getByTestId("transcript").getByText("What do you want me on first?", { exact: true }),
).toHaveCount(0);
await expect(
page.getByTestId("transcript").getByText("Start the conversation — New Bot is on stage above."),
).toBeVisible();
await captureScreenshot(page, testInfo, "create-chat-sidepanel-closed"); await captureScreenshot(page, testInfo, "create-chat-sidepanel-closed");
await page.getByTestId("toggle-bots-sidebar").click(); await page.getByTestId("toggle-bots-sidebar").click();
@ -114,20 +119,30 @@ test("later bot waits before showing the focus card; sending cancels it", async
await signup(page, `focus-delay-${stamp}@rakazo.test`, "password12", "Focus Delay"); await signup(page, `focus-delay-${stamp}@rakazo.test`, "password12", "Focus Delay");
await completeOnboarding(page, undefined, { mainConversation: true }); await completeOnboarding(page, undefined, { mainConversation: true });
// The main bot shows the focus card immediately in the main conversation. // The main bot shows the focus card immediately in the main conversation.
await expect(page.getByText("What do you want me on first?", { exact: true })).toBeVisible(); await expect(
page.getByTestId("transcript").getByText("What do you want me on first?", { exact: true }),
).toBeVisible();
await page.clock.install(); await page.clock.install();
await createBotFromPicker(page); await createBotFromPicker(page);
await expect(page.getByRole("combobox", { name: "Message" })).toBeVisible(); await expect(page.getByRole("combobox", { name: "Message" })).toBeVisible();
await expect(page.getByText("What do you want me on first?", { exact: true })).toHaveCount(0); await expect(
page.getByTestId("transcript").getByText("What do you want me on first?", { exact: true }),
).toHaveCount(0);
await page.clock.fastForward(9_000); await page.clock.fastForward(9_000);
await expect(page.getByText("What do you want me on first?", { exact: true })).toHaveCount(0); await expect(
page.getByTestId("transcript").getByText("What do you want me on first?", { exact: true }),
).toHaveCount(0);
await page.clock.fastForward(1_500); await page.clock.fastForward(1_500);
await expect(page.getByText("What do you want me on first?", { exact: true })).toBeVisible(); await expect(
page.getByTestId("transcript").getByText("What do you want me on first?", { exact: true }),
).toBeVisible();
await createBotFromPicker(page, { name: "Later Bot" }); await createBotFromPicker(page, { name: "Later Bot" });
await expect(page.getByText("What do you want me on first?", { exact: true })).toHaveCount(0); await expect(
page.getByTestId("transcript").getByText("What do you want me on first?", { exact: true }),
).toHaveCount(0);
const composer = page.getByPlaceholder(/Message/); const composer = page.getByPlaceholder(/Message/);
await composer.fill("I'll set this up myself"); await composer.fill("I'll set this up myself");
// Send must finish before the delay is advanced: cancel runs after a successful // Send must finish before the delay is advanced: cancel runs after a successful
@ -141,7 +156,9 @@ test("later bot waits before showing the focus card; sending cancels it", async
page.getByTestId("transcript").getByText("I'll set this up myself").first(), page.getByTestId("transcript").getByText("I'll set this up myself").first(),
).toBeVisible(); ).toBeVisible();
await page.clock.fastForward(12_000); await page.clock.fastForward(12_000);
await expect(page.getByText("What do you want me on first?", { exact: true })).toHaveCount(0); await expect(
page.getByTestId("transcript").getByText("What do you want me on first?", { exact: true }),
).toHaveCount(0);
}); });
test("plus picker can create a Private computer bot", async ({ page }, testInfo) => { test("plus picker can create a Private computer bot", async ({ page }, testInfo) => {

View File

@ -0,0 +1,130 @@
/**
* E2E: Settings → Notifications turns push on for this browser (the Push API is stubbed: headless
* Chromium has no push service), sets quiet hours and switches nudges off; all of it survives a
* reload.
*/
import type { Page } from "@playwright/test";
import { expect, test } from "@playwright/test";
import { captureScreenshot, completeOnboarding, openUserSettings, rpc, signup } from "./helpers";
const ENDPOINT = "https://push.example.test/e2e-device";
/**
* A fake Notification permission and service worker registration, kept in sessionStorage so they
* survive a reload. Headless Chromium reports notifications as denied even when granted.
*/
async function stubPushApi(page: Page, endpoint: string) {
await page.addInitScript((pushEndpoint) => {
const permissionKey = "e2e:notification-permission";
const notification = Object.defineProperties(function Notification() {}, {
permission: { get: () => sessionStorage.getItem(permissionKey) ?? "default" },
requestPermission: {
value: async () => {
sessionStorage.setItem(permissionKey, "granted");
return "granted";
},
},
});
Object.defineProperty(window, "Notification", {
configurable: true,
writable: true,
value: notification,
});
const storageKey = "e2e:push-subscription";
const current = () => {
const stored = sessionStorage.getItem(storageKey);
if (!stored) return null;
const saved = JSON.parse(stored) as { serverKey: number[] };
return {
endpoint: pushEndpoint,
options: { applicationServerKey: new Uint8Array(saved.serverKey).buffer },
toJSON: () => ({
endpoint: pushEndpoint,
keys: { p256dh: `B${"A".repeat(86)}`, auth: "A".repeat(22) },
}),
unsubscribe: async () => {
sessionStorage.removeItem(storageKey);
return true;
},
};
};
const registration = {
scope: `${location.origin}/`,
pushManager: {
getSubscription: async () => current(),
subscribe: async (options: { applicationServerKey: Uint8Array }) => {
sessionStorage.setItem(
storageKey,
JSON.stringify({ serverKey: [...new Uint8Array(options.applicationServerKey)] }),
);
return current();
},
},
};
Object.defineProperty(navigator, "serviceWorker", {
configurable: true,
value: {
register: async () => registration,
getRegistration: async () => registration,
ready: Promise.resolve(registration),
},
});
if (!("PushManager" in window)) {
Object.defineProperty(window, "PushManager", { configurable: true, value: class {} });
}
}, endpoint);
}
test("notification settings: push on this device, quiet hours and nudges", async ({
page,
}, testInfo) => {
await stubPushApi(page, ENDPOINT);
await signup(page, `notifications-${Date.now()}@rakazo.test`, "password12", "Push Tester");
await completeOnboarding(page);
let settings = await openUserSettings(page, "notifications");
const panel = settings.getByTestId("notification-settings");
const push = panel.getByTestId("notification-push-switch");
await expect(push).not.toBeChecked();
const subscribed = page.waitForRequest("**/rpc/notifications/webPush/subscribe");
await push.click();
expect((await subscribed).postDataJSON()).toMatchObject({ json: { endpoint: ENDPOINT } });
await expect(push).toBeChecked();
const quiet = panel.getByTestId("notification-quiet-hours-switch");
await expect(quiet).not.toBeChecked();
await quiet.click();
await expect(quiet).toBeChecked();
const start = panel.getByTestId("notification-quiet-hours-start");
await expect(start).toHaveValue("22:00");
await expect(panel.getByTestId("notification-quiet-hours-end")).toHaveValue("07:00");
const saved = page.waitForResponse(
(response) =>
response.url().includes("/rpc/notifications/settings/update") &&
response.request().postData()?.includes("23:30") === true,
);
await start.fill("23:30");
await saved;
const nudges = panel.getByTestId("notification-nudges-switch");
await expect(nudges).toBeChecked();
await nudges.click();
await expect(nudges).not.toBeChecked();
await expect
.poll(() => rpc<{ nudges: boolean }>(page, "notifications/settings/get", null))
.toMatchObject({ nudges: false, quietHours: { start: "23:30", end: "07:00" } });
await captureScreenshot(page, testInfo, "notification-settings");
await page.reload();
settings = await openUserSettings(page, "notifications");
await expect(settings.getByTestId("notification-push-switch")).toBeChecked();
await expect(settings.getByTestId("notification-quiet-hours-start")).toHaveValue("23:30");
await expect(settings.getByTestId("notification-nudges-switch")).not.toBeChecked();
const unsubscribed = page.waitForRequest("**/rpc/notifications/webPush/unsubscribe");
await settings.getByTestId("notification-push-switch").click();
expect((await unsubscribed).postDataJSON()).toMatchObject({ json: { endpoint: ENDPOINT } });
await expect(settings.getByTestId("notification-push-switch")).not.toBeChecked();
});

View File

@ -16,11 +16,16 @@ test("focus choice suggests apps and preserves a completed connection", async ({
await expect( await expect(
page.getByText("Hey Robin. Fresh start on my side, so I’ll keep this short."), page.getByText("Hey Robin. Fresh start on my side, so I’ll keep this short."),
).toHaveCount(0); ).toHaveCount(0);
await expect(page.getByText("What do you want me on first?", { exact: true })).toBeVisible(); await expect(
page.getByTestId("transcript").getByText("What do you want me on first?", { exact: true }),
).toBeVisible();
await expect(page.getByTestId("focus-choice-day")).toBeVisible(); await expect(page.getByTestId("focus-choice-day")).toBeVisible();
await expect(page.getByTestId("focus-choice-day")).toContainText("Day-to-day work"); await expect(page.getByTestId("focus-choice-day")).toContainText("Day-to-day work");
await expect(page.getByTestId("focus-choice-research")).toContainText("Research & writing"); await expect(page.getByTestId("focus-choice-research")).toContainText("Research & writing");
await expect(page.getByTestId("focus-choice-crew")).toContainText("Specialist crew"); await expect(page.getByTestId("focus-choice-crew")).toContainText("Specialist crew");
// Onboarding questions offer a quiet way out instead of a dismiss button.
await expect(page.getByTestId("focus-choice-skip")).toBeVisible();
await expect(page.getByRole("button", { name: "Dismiss" })).toHaveCount(0);
await page.mouse.move(1, 1); await page.mouse.move(1, 1);
await captureScreenshot(page, testInfo, "01-focus-choice"); await captureScreenshot(page, testInfo, "01-focus-choice");
await captureScreenshot(page, testInfo, "choice-card-onboarding"); await captureScreenshot(page, testInfo, "choice-card-onboarding");
@ -33,6 +38,8 @@ test("focus choice suggests apps and preserves a completed connection", async ({
).toBeVisible(); ).toBeVisible();
expect((await rpc<{ name: string }>(page, "bots/ensureMain", {})).name).toBe("Chief"); expect((await rpc<{ name: string }>(page, "bots/ensureMain", {})).name).toBe("Chief");
await expect(page.getByRole("combobox", { name: "Message" })).toBeVisible(); await expect(page.getByRole("combobox", { name: "Message" })).toBeVisible();
// The app cards speak for themselves; onboarding adds no text around them.
await expect(page.getByText(/^Got it/)).toHaveCount(0);
await expect(page.getByText("Slack", { exact: true })).toBeVisible(); await expect(page.getByText("Slack", { exact: true })).toBeVisible();
await expect(page.getByText("Gmail", { exact: true })).toBeVisible(); await expect(page.getByText("Gmail", { exact: true })).toBeVisible();
const connectionCards = page.getByRole("group", { name: / connection$/ }); const connectionCards = page.getByRole("group", { name: / connection$/ });
@ -45,10 +52,10 @@ test("focus choice suggests apps and preserves a completed connection", async ({
); );
expect(cardBoxes[1].top - cardBoxes[0].bottom).toBeGreaterThanOrEqual(8); expect(cardBoxes[1].top - cardBoxes[0].bottom).toBeGreaterThanOrEqual(8);
expect(cardBoxes[2].top - cardBoxes[1].bottom).toBeGreaterThanOrEqual(8); expect(cardBoxes[2].top - cardBoxes[1].bottom).toBeGreaterThanOrEqual(8);
await page // The next onboarding question follows the suggestions.
.getByTestId("transcript") const goal = page.locator('[data-testid="choice-card"][data-step="goal"]');
.getByText("Hit those three and I’ll start pulling the picture.") await expect(goal).toContainText("What’s one thing you want to get done in the next few weeks?");
.scrollIntoViewIfNeeded(); await slackCard(page).scrollIntoViewIfNeeded();
await page.mouse.move(1, 1); await page.mouse.move(1, 1);
await captureScreenshot(page, testInfo, "02-app-suggestions"); await captureScreenshot(page, testInfo, "02-app-suggestions");
const authorizeButton = slackCard(page).getByRole("button", { name: "Authorize" }); const authorizeButton = slackCard(page).getByRole("button", { name: "Authorize" });

View File

@ -0,0 +1,77 @@
/**
* E2E: the deployment owner's onboarding asks before scanning the fixture home folder the
* harness configures, then adds only the projects and facts the user picks.
*/
import type { Page } from "@playwright/test";
import { expect, test } from "@playwright/test";
import type { SpaceNavigation, UserProfileState } from "@rakazo/contracts";
import { captureScreenshot, rpc, signup } from "./helpers";
const api = process.env.API_URL ?? "http://127.0.0.1:3110";
/** Advances whichever wizard step is showing until the scan step is reached. */
async function walkToScanStep(page: Page) {
const surface = page.locator('[data-rakazo-surface="onboarding"]');
await expect(async () => {
if ((await surface.getAttribute("data-onboarding-step")) === "scan") return;
const skip = page.getByRole("button", { name: /^Skip/ }).first();
const next = (await skip.isVisible())
? skip
: page.getByRole("button", { name: "Continue", exact: true });
await next.click({ timeout: 2_000 });
await expect(surface).toHaveAttribute("data-onboarding-step", "scan", { timeout: 1_000 });
}).toPass({ timeout: 60_000 });
}
test("asks before looking for projects and adds only what the user picks", async ({
page,
}, testInfo) => {
const email = `discovery-${Date.now()}@rakazo.test`;
await signup(page, email, "password12", "Discovery");
await page.waitForURL(/\/onboarding/, { timeout: 20_000 });
// The harness starts without a deployment owner; this spec takes the seat for its run.
const owner = await page.request.post(`${api}/__e2e/deployment-owner`, { data: { email } });
expect(owner.ok()).toBe(true);
const { previousOwnerUserId } = (await owner.json()) as { previousOwnerUserId: string | null };
try {
await page.goto("/onboarding", { waitUntil: "domcontentloaded" });
await walkToScanStep(page);
await expect(page.getByRole("heading", { name: "Look for projects here?" })).toBeVisible();
await expect(page.getByText(/rakazo-e2e-scan-home-/)).toBeVisible();
await captureScreenshot(page, testInfo, "onboarding-discovery-consent");
await page.getByRole("button", { name: "Look", exact: true }).click();
const projects = page.getByRole("group", { name: "Add as projects" });
await expect(projects.getByRole("checkbox")).toHaveCount(2);
const atlas = projects.getByRole("checkbox", { name: /atlas/ });
await expect(atlas).not.toBeChecked();
await expect(projects.getByRole("checkbox", { name: /garden/ })).not.toBeChecked();
await atlas.check();
const facts = page.getByRole("group", { name: "Add to About you" });
await expect(facts.getByRole("checkbox", { name: "TypeScript" })).toBeChecked();
await facts.getByRole("checkbox", { name: "Python" }).uncheck();
await captureScreenshot(page, testInfo, "onboarding-discovery-results");
await page.getByRole("button", { name: "Add", exact: true }).click();
await page.waitForURL(/\/app\/g\/[^/]+$/, { timeout: 30_000 });
const navigation = await rpc<SpaceNavigation>(page, "spaces/list", {});
const names = navigation.spaces.map((space) => space.name);
expect(names).toContain("atlas");
expect(names).not.toContain("garden");
expect(navigation.spaces.find((space) => space.name === "atlas")?.workspace?.path).toMatch(
/code\/atlas$/,
);
const values = (await rpc<UserProfileState>(page, "profile/get", {})).facts.map(
(fact) => fact.value,
);
expect(values).toEqual(
expect.arrayContaining(["Codes in TypeScript", "Uses Docker", "Uses GitHub Actions"]),
);
expect(values).not.toContain("Codes in Python");
} finally {
await page.request.post(`${api}/__e2e/deployment-owner`, {
data: { userId: previousOwnerUserId },
});
}
});

View File

@ -17,12 +17,11 @@ test.beforeEach(async ({ page }) => {
}); });
/** /**
* E2E test for the new emoji-first onboarding flow: * E2E test for the emoji-first onboarding flow:
* 1. Sign up → lands on onboarding * 1. Sign up → lands on onboarding
* 2. Design Oo: pick shape + color → live preview * 2. Design Oo: pick shape + color → live preview
* 3. Tell us about yourself: name, timezone, hobbies, work style, communication * 3. Choose language, skip the voice
* 4. Choose language * 4. Skip integrations → bot created → land in chat, where the main bot asks the rest
* 5. Skip integrations → bot created → land in chat
*/ */
test("emoji-first onboarding: shape + color selection with live preview", async ({ test("emoji-first onboarding: shape + color selection with live preview", async ({
@ -50,50 +49,13 @@ test("emoji-first onboarding: shape + color selection with live preview", async
if (testInfo) await captureScreenshot(page, testInfo, "02-shape-color-preview"); if (testInfo) await captureScreenshot(page, testInfo, "02-shape-color-preview");
// Click Continue → goes to preferences step // Click Continue → goes to the language step
await page await page
.getByRole("button", { name: /Continue/i }) .getByRole("button", { name: /Continue/i })
.first() .first()
.click(); .click();
// Step 2: Tell us about yourself // Step 2: Language selection
await expect(page.getByRole("heading", { name: /Tell us about yourself/i })).toBeVisible();
if (testInfo) await captureScreenshot(page, testInfo, "03-user-preferences");
// Fill in name
await page.getByPlaceholder(/Your name/i).fill("Alice");
// Fill in timezone
await page.getByLabel(/Timezone/i).fill("Europe/Vienna");
// Select a hobby
const hobbyButtons = page.locator("button[aria-pressed]");
if (await hobbyButtons.count()) {
await hobbyButtons.first().click(); // toggle first hobby
}
// Select work style
const workStyleButtons = page.locator("button[aria-pressed]:visible");
if ((await workStyleButtons.count()) >= 2) {
await workStyleButtons.nth(0).click();
}
// Select communication style
const commButtons = page.locator("button[aria-pressed]:visible");
if ((await commButtons.count()) >= 2) {
await commButtons.nth(0).click();
}
if (testInfo) await captureScreenshot(page, testInfo, "04-preferences-filled");
// Click Continue → goes to language step
await page
.getByRole("button", { name: /Continue/i })
.first()
.click();
// Step 3: Language selection
await expect(page.getByRole("heading", { name: /Choose your language/i })).toBeVisible(); await expect(page.getByRole("heading", { name: /Choose your language/i })).toBeVisible();
if (testInfo) await captureScreenshot(page, testInfo, "05-language-step"); if (testInfo) await captureScreenshot(page, testInfo, "05-language-step");
@ -135,13 +97,6 @@ test("onboarding: skip language defaults to English", async ({ page }) => {
.first() .first()
.click(); .click();
// Preferences step — just continue
await expect(page.getByRole("heading", { name: /Tell us about yourself/i })).toBeVisible();
await page
.getByRole("button", { name: /Continue/i })
.first()
.click();
// Language step — click Skip, then skip voice. // Language step — click Skip, then skip voice.
await expect(page.getByRole("heading", { name: /Choose your language/i })).toBeVisible(); await expect(page.getByRole("heading", { name: /Choose your language/i })).toBeVisible();
await page.getByRole("button", { name: /Skip/i }).click(); await page.getByRole("button", { name: /Skip/i }).click();

View File

@ -0,0 +1,91 @@
/**
* E2E: after the wizard the main bot gets to know the user in the main conversation, one card
* after each answer, without ever blocking the composer.
*/
import type { Page } from "@playwright/test";
import { expect, test } from "@playwright/test";
import { captureScreenshot, completeOnboarding, rpc, signup } from "./helpers";
function card(page: Page, step: string) {
return page.locator(`[data-testid="choice-card"][data-step="${step}"]`);
}
test("the main bot asks focus, goal, check-ins and quiet hours, then says what is next", async ({
page,
}, testInfo) => {
await signup(page, `intake-${Date.now()}@rakazo.test`, "password12", "Intake Tester");
await completeOnboarding(page, undefined, { mainConversation: true });
await page.getByTestId("focus-choice-day").click();
const goal = card(page, "goal");
await expect(goal).toContainText("What’s one thing you want to get done in the next few weeks?");
// Typing instead of answering goes to the main bot, and the card stays answerable.
const composer = page.getByRole("combobox", { name: "Message" });
await composer.fill("Quick question first");
await composer.press("Enter");
await expect(page.getByTestId("transcript")).toContainText(/on it|handled/i, {
timeout: 60_000,
});
const goalInput = goal.getByRole("textbox", {
name: "What’s one thing you want to get done in the next few weeks?",
});
await goalInput.fill("Run a 10K");
await goal.scrollIntoViewIfNeeded();
await page.mouse.move(1, 1);
await captureScreenshot(page, testInfo, "onboarding-intake-goal");
await goalInput.press("Enter");
await expect(goal.getByTestId("choice-answer")).toHaveText("Run a 10K");
const rhythm = card(page, "rhythm");
await expect(rhythm).toContainText("When should I check in with you?");
// The sidebar previews the main conversation as the web words it: the pending question.
const mainEntry = page.locator("aside").first().locator("[data-main-conversation]");
await expect(mainEntry).toContainText("When should I check in with you?");
await rhythm.scrollIntoViewIfNeeded();
await page.mouse.move(1, 1);
await captureScreenshot(page, testInfo, "onboarding-intake-rhythm");
await rhythm.getByTestId("rhythm-choice-morning").click();
await expect(rhythm.getByTestId("choice-answer")).toContainText("Every morning");
const quiet = card(page, "quiet_hours");
await expect(quiet).toContainText("When should I stay quiet?");
await quiet.getByTestId("quiet_hours-choice-22-07").click();
// Only a browser that can still turn push on is asked about notifications.
// The web words the closing note itself, from the time of the first check-in.
const closing = page
.getByTestId("transcript")
.getByText(/^All set\. I’ll check in (today|tomorrow) at 8:00\sAM\.$/);
const notifications = card(page, "notifications");
await expect(notifications.or(closing).first()).toBeVisible();
if (await notifications.isVisible()) {
await notifications.getByTestId("notifications-choice-later").click();
}
await expect(closing).toBeVisible();
await expect(mainEntry).toContainText(/All set\. I’ll check in (today|tomorrow) at 8:00\sAM\./);
await expect(
page.locator('[data-testid="choice-card"]:not(:has([data-testid="choice-answer"]))'),
).toHaveCount(0);
await closing.scrollIntoViewIfNeeded();
await page.mouse.move(1, 1);
await captureScreenshot(page, testInfo, "onboarding-intake-done");
const settings = await rpc<{ quietHours: { start: string; end: string } | null }>(
page,
"notifications/settings/get",
null,
);
expect(settings.quietHours).toEqual({ start: "22:00", end: "07:00" });
const profile = await rpc<{ facts: Array<{ value: string; source: string }> }>(
page,
"profile/get",
null,
);
expect(profile.facts).toContainEqual(
expect.objectContaining({
value: "Wants help with day-to-day work first",
source: "onboarding",
}),
);
});

View File

@ -37,7 +37,7 @@ test("onboarding shows the look picker when a model is still required", async ({
await page.waitForURL(/\/onboarding/, { timeout: 20_000 }); await page.waitForURL(/\/onboarding/, { timeout: 20_000 });
await expect(page.locator('[data-rakazo-surface="onboarding"]')).toHaveAttribute( await expect(page.locator('[data-rakazo-surface="onboarding"]')).toHaveAttribute(
"data-onboarding-step", "data-onboarding-step",
/^(emoji|preferences|language|voice|integrations)$/, /^(emoji|language|voice|integrations)$/,
{ timeout: 30_000 }, { timeout: 30_000 },
); );
await expect(page.getByRole("heading", { name: /Design your Oo/i })).toBeVisible({ await expect(page.getByRole("heading", { name: /Design your Oo/i })).toBeVisible({
@ -56,7 +56,7 @@ for (const unavailable of ["empty", "failed"] as const) {
await page.waitForURL(/\/onboarding/, { timeout: 20_000 }); await page.waitForURL(/\/onboarding/, { timeout: 20_000 });
await expect(page.locator('[data-rakazo-surface="onboarding"]')).toHaveAttribute( await expect(page.locator('[data-rakazo-surface="onboarding"]')).toHaveAttribute(
"data-onboarding-step", "data-onboarding-step",
/^(emoji|preferences|language|voice|integrations)$/, /^(emoji|language|voice|integrations)$/,
{ timeout: 30_000 }, { timeout: 30_000 },
); );
await expect(page.getByRole("heading", { name: /Design your Oo/i })).toBeVisible({ await expect(page.getByRole("heading", { name: /Design your Oo/i })).toBeVisible({

View File

@ -77,5 +77,5 @@ test("mobile onboarding: category pick and swipe look previews", async ({ page }
.getByRole("button", { name: /Continue/i }) .getByRole("button", { name: /Continue/i })
.first() .first()
.click(); .click();
await expect(page.getByRole("heading", { name: /Tell us about yourself/i })).toBeVisible(); await expect(page.getByRole("heading", { name: /Choose your language/i })).toBeVisible();
}); });

View File

@ -50,6 +50,16 @@ test("live page helper preserves identity, masks passwords, and reports partial
expect(snapshot.title).toBe("Fixture"); expect(snapshot.title).toBe("Fixture");
expect(snapshot.tree).toContain("Readable article content"); expect(snapshot.tree).toContain("Readable article content");
expect(JSON.stringify(snapshot)).not.toContain("fake-test-password"); expect(JSON.stringify(snapshot)).not.toContain("fake-test-password");
// Element boxes are screen pixels for the visible bot cursor.
expect(snapshot.screen.width).toBeGreaterThan(0);
expect(snapshot.screen.height).toBeGreaterThan(0);
const saveBox = snapshot.elements.find((element: { name: string }) => element.name === "Save")
.box as number[];
const saveRect = (await page.locator("#save").boundingBox())!;
expect(saveBox[2]).toBe(Math.round(saveRect.width));
expect(saveBox[3]).toBe(Math.round(saveRect.height));
expect(saveBox[0]).toBeGreaterThanOrEqual(Math.round(saveRect.x));
expect(saveBox[1]).toBeGreaterThanOrEqual(Math.round(saveRect.y));
const ref = (snapshot: { elements: { name: string; ref: string }[] }, name: string) => const ref = (snapshot: { elements: { name: string; ref: string }[] }, name: string) =>
snapshot.elements.find((element) => element.name === name)!.ref; snapshot.elements.find((element) => element.name === name)!.ref;
const result = await command("act", { const result = await command("act", {

View File

@ -0,0 +1,87 @@
import { expect, type Locator, test } from "@playwright/test";
import { captureScreenshot, completeOnboarding, rpc, signup, waitForChatComposer } from "./helpers";
function projectSpace(projekte: Locator, name: string) {
return projekte.locator('[data-sidebar-group^="project-space:"]').filter({ hasText: name });
}
test("a new project opens straight into the conversation with its own main agent", async ({
page,
}, testInfo) => {
const stamp = Date.now();
await signup(page, `project-main-${stamp}@rakazo.test`, "password12", "Project Main");
await completeOnboarding(page);
const homeSpaceId = await page.evaluate(() => window.localStorage.getItem("rakazo:space-id"));
const homeMain = await rpc<{ id: string }>(page, "bots/ensureMain", {});
const projekte = page.locator(".sidebar-projekte");
await projekte.getByTestId("create-project-trigger").click();
const dialog = page.getByRole("dialog", { name: "New project" });
await dialog.getByLabel("Name").fill("Launch");
await dialog.getByRole("button", { name: "Create project", exact: true }).click();
await page.waitForURL(/\/app\/g\/[^/]+$/, { timeout: 30_000 });
await expect
.poll(() => page.evaluate(() => window.localStorage.getItem("rakazo:space-id")))
.not.toBe(homeSpaceId);
await waitForChatComposer(page);
// The project is one conversation, led by a main agent of its own.
const projectEntry = projectSpace(projekte, "Launch").getByRole("button", {
name: "Chief, main conversation",
});
await expect(projectEntry).toBeVisible({ timeout: 30_000 });
await expect(projekte.getByRole("button", { name: "Start", exact: true })).toHaveCount(0);
const groupId = new URL(page.url()).pathname.split("/").at(-1)!;
const snapshot = await rpc<{ leadBotId?: string }>(page, "threads/get", { groupId });
expect(snapshot.leadBotId).toBeTruthy();
expect(snapshot.leadBotId).not.toBe(homeMain.id);
const composer = page.getByRole("combobox", { name: "Message" });
await composer.fill("What should we do first?");
await composer.press("Enter");
const transcript = page.getByTestId("transcript");
await expect(transcript).toContainText(/on it|handled/i, { timeout: 60_000 });
await captureScreenshot(page, testInfo, "project-main-conversation");
});
test("a project's main agent hands work to the project's specialist", async ({
page,
}, testInfo) => {
const stamp = Date.now();
await signup(page, `project-delegate-${stamp}@rakazo.test`, "password12", "Project Delegate");
await completeOnboarding(page);
const projekte = page.locator(".sidebar-projekte");
await projekte.getByTestId("create-project-trigger").click();
const dialog = page.getByRole("dialog", { name: "New project" });
await dialog.getByLabel("Name").fill("Website");
await dialog.getByRole("button", { name: "Create project", exact: true }).click();
await page.waitForURL(/\/app\/g\/[^/]+$/, { timeout: 30_000 });
await waitForChatComposer(page);
const groupId = new URL(page.url()).pathname.split("/").at(-1)!;
const designer = await rpc<{ id: string }>(page, "bots/create", {
name: "Designer",
title: "",
description: "",
instructions: "",
notifyOnFinish: true,
});
const composer = page.getByRole("combobox", { name: "Message" });
await composer.fill("delegate to the bot named Designer: sketch a landing page");
await composer.press("Enter");
const transcript = page.getByTestId("transcript");
const card = transcript.getByTestId("delegated-task-card");
await expect(card).toBeVisible({ timeout: 60_000 });
await expect(card).toContainText("Designer");
await expect(card).toHaveAttribute("data-status", "done", { timeout: 60_000 });
await expect(
transcript.getByText("closing the task: sketch a landing page.").first(),
).toBeVisible({ timeout: 60_000 });
const snapshot = await rpc<{ members?: Array<{ botId: string }> }>(page, "threads/get", {
groupId,
});
expect(snapshot.members?.map((member) => member.botId)).toContain(designer.id);
await captureScreenshot(page, testInfo, "project-main-agent-delegates");
});

View File

@ -21,30 +21,21 @@ test("sidebar has expected navigation items and no integrations", async ({ page
// Actual sidebar structure (verified by snapshot): // Actual sidebar structure (verified by snapshot):
// - button "Activity" (top action) // - button "Activity" (top action)
// - textbox "Search" (search input) // - textbox "Search" (search input)
// - button "Heute" (today) // - button "Today"
// - text "Sprich mit deinen Oo's" (conversations section header) // - text "Talk to your Oos" (conversations section header)
// - status "Not configured" (trading bot URL unset)
// - NO "Integration(en)" button or text // - NO "Integration(en)" button or text
// Search: it's a textbox, not a button // Search: it's a textbox, not a button
const searchInput = sidebar.locator('input[placeholder="Search"], textbox[name="Search"]'); const searchInput = sidebar.locator('input[placeholder="Search"], textbox[name="Search"]');
await expect(searchInput).toBeVisible({ timeout: 5000 }); await expect(searchInput).toBeVisible({ timeout: 5000 });
// "Heute" (Today) const todayBtn = sidebar.getByRole("button", { name: "Today", exact: true });
const todayBtn = sidebar.getByRole("button", { name: "Heute", exact: true });
await expect(todayBtn).toBeVisible({ timeout: 5000 }); await expect(todayBtn).toBeVisible({ timeout: 5000 });
// "Sprich mit deinen Oo's" is plain text, not a button // "Talk to your Oos" is plain text, not a button
const conversationsText = sidebar.getByText("Sprich mit deinen Oo's"); const conversationsText = sidebar.getByText("Talk to your Oos");
await expect(conversationsText).toBeVisible({ timeout: 5000 }); await expect(conversationsText).toBeVisible({ timeout: 5000 });
// Trading mode comes from the bot API. With no URL configured the sidebar
// must not claim Live Trading from a local preference.
const tradingStatus = sidebar.getByTestId("trading-mode-status");
await expect(tradingStatus).toBeVisible({ timeout: 5000 });
await expect(tradingStatus).toContainText("Not configured");
await expect(sidebar.getByRole("button", { name: "Live Trading" })).toHaveCount(0);
// No "Integration(en)" button // No "Integration(en)" button
const integrationBtn = sidebar.getByRole("button", { name: /Integration(en)?/i }); const integrationBtn = sidebar.getByRole("button", { name: /Integration(en)?/i });
await expect(integrationBtn).toHaveCount(0, { timeout: 5000 }); await expect(integrationBtn).toHaveCount(0, { timeout: 5000 });

View File

@ -41,8 +41,8 @@ test("app loads without React hook errors", async ({ page }) => {
bodyText.includes("Sign in") || bodyText.includes("Sign in") ||
bodyText.includes("Anmelden") || bodyText.includes("Anmelden") ||
bodyText.includes("EvOo") || bodyText.includes("EvOo") ||
bodyText.includes("Projekte") || bodyText.includes("Projects") ||
bodyText.includes("Heute") || bodyText.includes("Today") ||
bodyText.includes("Team"); bodyText.includes("Team");
expect(hasContent).toBe(true); expect(hasContent).toBe(true);

View File

@ -3,6 +3,7 @@ import {
captureScreenshot, captureScreenshot,
completeOnboarding, completeOnboarding,
openNewSpace, openNewSpace,
routePatchRpc,
rpc, rpc,
signup, signup,
unterhaltungenOnly, unterhaltungenOnly,
@ -15,7 +16,7 @@ function projectSpace(projekte: Locator, name: string) {
}); });
} }
test("Projekte + opens new project dialog instead of group form", async ({ page }) => { test("Projects + opens new project dialog instead of group form", async ({ page }, testInfo) => {
const stamp = Date.now(); const stamp = Date.now();
await signup(page, `proj-create-${stamp}@rakazo.test`, "password12", "Project Create"); await signup(page, `proj-create-${stamp}@rakazo.test`, "password12", "Project Create");
await completeOnboarding(page); await completeOnboarding(page);
@ -33,18 +34,47 @@ test("Projekte + opens new project dialog instead of group form", async ({ page
await dialog.getByLabel("Name").fill("Customer project"); await dialog.getByLabel("Name").fill("Customer project");
await dialog.getByRole("button", { name: "Create project", exact: true }).click(); await dialog.getByRole("button", { name: "Create project", exact: true }).click();
await expect(page).not.toHaveURL(/\/onboarding/); await expect(page).not.toHaveURL(/\/onboarding/);
await page.waitForURL(/\/app/); // The new project opens its conversation with the project's main agent.
await page.waitForLoadState("networkidle"); await page.waitForURL(/\/app\/g\/[^/]+$/, { timeout: 30_000 });
await expect(projekte.getByText("Customer project", { exact: true })).toBeVisible({ await expect(projekte.getByText("Customer project", { exact: true })).toBeVisible({
timeout: 30_000, timeout: 30_000,
}); });
await expect(unterhaltungenOnly(page).getByText("Customer project", { exact: true })).toHaveCount( await expect(unterhaltungenOnly(page).getByText("Customer project", { exact: true })).toHaveCount(
0, 0,
); );
await expect(page.getByTestId("project-start-main")).toBeVisible(); await expect(
await expect(page.getByTestId("project-start-main")).toHaveText(/Starten/); projectSpace(projekte, "Customer project").getByRole("button", {
name: "Chief, main conversation",
}),
).toBeVisible();
await expect(page.getByTestId("project-start-main")).toHaveCount(0);
await captureScreenshot(page, testInfo, "new-project-main-conversation");
}); });
for (const owner of [true, false]) {
const role = owner ? "owner" : "member";
const title = owner
? "new project dialog offers a host folder to the deployment owner"
: "new project dialog offers no host folder to a member";
test(title, async ({ page }, testInfo) => {
await signup(page, `proj-folder-${role}-${Date.now()}@rakazo.test`, "password12", "Folder");
await completeOnboarding(page);
// Whoever signs up first owns the deployment, which depends on spec order.
await routePatchRpc(page, "bootstrap", (json) => ({
...json,
me: { ...(json.me as Record<string, unknown>), isDeploymentOwner: owner },
}));
await page.goto("/app");
await page.waitForURL(/\/app\/[^/]+$/);
await page.locator(".sidebar-projekte").getByTestId("create-project-trigger").click();
const dialog = page.getByRole("dialog", { name: "New project" });
await expect(dialog.getByLabel("Name")).toBeVisible();
await expect(dialog.getByLabel("Project folder")).toHaveCount(owner ? 1 : 0);
await captureScreenshot(page, testInfo, `new-project-${role}`);
});
}
test("spaces stay invisible by default and chat creation requires approval", async ({ test("spaces stay invisible by default and chat creation requires approval", async ({
page, page,
}, testInfo) => { }, testInfo) => {
@ -94,17 +124,18 @@ test("spaces stay invisible by default and chat creation requires approval", asy
const supportSpaceGroup = await supportSpace.getAttribute("data-sidebar-group"); const supportSpaceGroup = await supportSpace.getAttribute("data-sidebar-group");
const supportSpaceId = supportSpaceGroup?.match(/^project-space:([^:]+)/)?.[1]; const supportSpaceId = supportSpaceGroup?.match(/^project-space:([^:]+)/)?.[1];
expect(supportSpaceId).toBeTruthy(); expect(supportSpaceId).toBeTruthy();
await supportSpace.getByRole("button", { name: "Select Customer support" }).click(); // A project created from chat comes with its own main agent, reached through its conversation.
await page.waitForURL(/\/app(?:\/)?$/); await supportSpace.getByRole("button", { name: "Chief, main conversation" }).click();
await page.waitForURL(/\/app\/g\/[^/]+$/);
await expect await expect
.poll(() => page.evaluate(() => window.localStorage.getItem("rakazo:space-id"))) .poll(() => page.evaluate(() => window.localStorage.getItem("rakazo:space-id")))
.toBe(supportSpaceId); .toBe(supportSpaceId);
// The main conversation stays the first entry while another space is open. // The home main conversation stays the first entry while another space is open.
await expect(projekte.getByText("Customer support", { exact: true })).toBeVisible(); await expect(projekte.getByText("Customer support", { exact: true })).toBeVisible();
await expect(unterhaltungenOnly(page).getByRole("button", { name: /^Chief/ })).toHaveCount(1); await expect(unterhaltungenOnly(page).getByRole("button", { name: /^Chief/ })).toHaveCount(1);
await expect(projekte.getByRole("button", { name: /^Chief/ })).toHaveCount(0); await expect(projekte.getByRole("button", { name: /^Chief/ })).toHaveCount(1);
await expect(projekte.getByRole("button", { name: "Starten", exact: true })).toBeVisible(); await expect(projekte.getByRole("button", { name: "Start", exact: true })).toHaveCount(0);
await captureScreenshot(page, testInfo, "spaces-sidebar"); await captureScreenshot(page, testInfo, "spaces-sidebar");
await page.evaluate((id) => { await page.evaluate((id) => {
@ -118,7 +149,9 @@ test("spaces stay invisible by default and chat creation requires approval", asy
await expect(projekte.getByText("Customer support", { exact: true })).toBeVisible(); await expect(projekte.getByText("Customer support", { exact: true })).toBeVisible();
}); });
test("a new space opens empty and can be deleted from its menu", async ({ page }, testInfo) => { test("a new space opens its main conversation and can be deleted from its menu", async ({
page,
}, testInfo) => {
const stamp = Date.now(); const stamp = Date.now();
await signup(page, `spaces-delete-${stamp}@rakazo.test`, "password12", "Space Owner"); await signup(page, `spaces-delete-${stamp}@rakazo.test`, "password12", "Space Owner");
await completeOnboarding(page); await completeOnboarding(page);
@ -129,18 +162,20 @@ test("a new space opens empty and can be deleted from its menu", async ({ page }
await dialog.getByLabel("Name").fill("Temporary"); await dialog.getByLabel("Name").fill("Temporary");
await dialog.getByRole("button", { name: "Create space", exact: true }).click(); await dialog.getByRole("button", { name: "Create space", exact: true }).click();
// Onboarding runs once per user: a new space opens directly, with no bot of its own. // Onboarding runs once per user: a new space opens directly in the conversation with its
await page.waitForURL(/\/app(?:\/)?$/); // own main agent, its only bot.
await page.waitForURL(/\/app\/g\/[^/]+$/, { timeout: 30_000 });
const projekte = page.locator(".sidebar-projekte"); const projekte = page.locator(".sidebar-projekte");
await expect(projekte.getByText("Temporary", { exact: true })).toBeVisible(); await expect(projekte.getByText("Temporary", { exact: true })).toBeVisible();
await expect(unterhaltungenOnly(page).getByRole("button", { name: /^Chief/ })).toHaveCount(1); await expect(unterhaltungenOnly(page).getByRole("button", { name: /^Chief/ })).toHaveCount(1);
await expect(projekte.getByRole("button", { name: /^Chief/ })).toHaveCount(0); await expect(
await expect(projekte.getByRole("button", { name: "Starten", exact: true })).toBeVisible(); projectSpace(projekte, "Temporary").getByRole("button", { name: "Chief, main conversation" }),
expect(await rpc<unknown[]>(page, "bots/list", {})).toEqual([]); ).toBeVisible();
await captureScreenshot(page, testInfo, "new-space-empty"); await expect(projekte.getByRole("button", { name: "Start", exact: true })).toHaveCount(0);
expect(await rpc<Array<{ kind: string }>>(page, "bots/list", {})).toEqual([
// The empty space stays selectable instead of trapping the user. expect.objectContaining({ kind: "main" }),
await expect(projekte.getByRole("button", { name: "Select Temporary" }).first()).toBeVisible(); ]);
await captureScreenshot(page, testInfo, "new-space-main-conversation");
// Deletion is discoverable through the visible Space actions menu. // Deletion is discoverable through the visible Space actions menu.
await projectSpace(projekte, "Temporary") await projectSpace(projekte, "Temporary")
@ -155,6 +190,7 @@ test("a new space opens empty and can be deleted from its menu", async ({ page }
await deleteSpaceDialog.getByRole("button", { name: "Delete", exact: true }).click(); await deleteSpaceDialog.getByRole("button", { name: "Delete", exact: true }).click();
await expect(sidebar.getByText("Temporary", { exact: true })).toHaveCount(0); await expect(sidebar.getByText("Temporary", { exact: true })).toHaveCount(0);
await expect(page).not.toHaveURL(/\/onboarding/); await expect(page).not.toHaveURL(/\/onboarding/);
// The project's main agent went with it.
await expect(sidebar.getByRole("button", { name: /^Chief/ })).toHaveCount(1); await expect(sidebar.getByRole("button", { name: /^Chief/ })).toHaveCount(1);
// A stale onboarding URL recovers without duplicating the bot. // A stale onboarding URL recovers without duplicating the bot.
@ -187,7 +223,7 @@ test("deleting the last bot in a space stays in the app after first use", async
const dialog = page.getByRole("dialog", { name: "New space" }); const dialog = page.getByRole("dialog", { name: "New space" });
await dialog.getByLabel("Name").fill("Side"); await dialog.getByLabel("Name").fill("Side");
await dialog.getByRole("button", { name: "Create space", exact: true }).click(); await dialog.getByRole("button", { name: "Create space", exact: true }).click();
await page.waitForURL(/\/app(?:\/)?$/); await page.waitForURL(/\/app\/g\/[^/]+$/, { timeout: 30_000 });
const projekte = page.locator(".sidebar-projekte"); const projekte = page.locator(".sidebar-projekte");
await expect(projekte.getByText("Side", { exact: true })).toBeVisible(); await expect(projekte.getByText("Side", { exact: true })).toBeVisible();
@ -204,8 +240,11 @@ test("deleting the last bot in a space stays in the app after first use", async
await expect(page).not.toHaveURL(/\/onboarding/); await expect(page).not.toHaveURL(/\/onboarding/);
await page.goto("/app"); await page.goto("/app");
await expect(page).not.toHaveURL(/\/onboarding/); await expect(page).not.toHaveURL(/\/onboarding/);
await expect(projekte.getByRole("button", { name: "Select Side" }).first()).toBeVisible(); // The project's main conversation stays, so the project opens there.
await expect(page.getByTestId("project-start-main")).toBeVisible(); await page.waitForURL(/\/app\/g\/[^/]+$/, { timeout: 30_000 });
await expect(
projectSpace(projekte, "Side").getByRole("button", { name: "Chief, main conversation" }),
).toBeVisible();
await projectSpace(projekte, "Side").getByRole("button", { name: "Actions for Side" }).click(); await projectSpace(projekte, "Side").getByRole("button", { name: "Actions for Side" }).click();
await expect(page.getByRole("menuitem", { name: "Delete space" })).toBeVisible(); await expect(page.getByRole("menuitem", { name: "Delete space" })).toBeVisible();

View File

@ -1,16 +0,0 @@
import { test } from "@playwright/test";
test.describe("Steve Jobs CTO Agent", () => {
test.beforeEach(async () => {
// We assume the user has logged in and the bot is seeded
// This is a placeholder for the perfect test architecture
});
test("should be visible in the sidebar", async () => {
// This is a dummy test to show we started the test architecture for Steve Jobs
});
test("should prefer voice communications (VOICE FIRST)", async () => {
// Voice first features
});
});

View File

@ -67,6 +67,9 @@ test("account settings appearance control switches to light mode", async ({ page
await settings.getByRole("button", { name: "Close user settings" }).click(); await settings.getByRole("button", { name: "Close user settings" }).click();
await expect(settings).toBeHidden(); await expect(settings).toBeHidden();
await expect(page.locator("html")).toHaveAttribute("data-theme", "light"); await expect(page.locator("html")).toHaveAttribute("data-theme", "light");
// Light surfaces stay light: no dark panel or white-on-paper text survives the switch.
await expect(page.locator("main").first()).toHaveCSS("background-color", "rgb(255, 255, 255)");
await expect(page.locator("aside").first()).toHaveCSS("background-color", "rgb(245, 245, 242)");
await captureScreenshot(page, testInfo, "ui-appearance-light-shell"); await captureScreenshot(page, testInfo, "ui-appearance-light-shell");
await captureSidebarSearchSelected(page, testInfo, "sidebar-search-selected-light"); await captureSidebarSearchSelected(page, testInfo, "sidebar-search-selected-light");

View File

@ -1,7 +1,7 @@
import { expect, test } from "@playwright/test"; import { expect, test } from "@playwright/test";
import { completeOnboarding, openNewBot, signup, unterhaltungenOnly } from "./helpers"; import { completeOnboarding, openNewBot, signup, unterhaltungenOnly } from "./helpers";
test("Unterhaltungen and Projekte stay separate; + opens bot picker", async ({ page }) => { test("conversations and projects stay separate; + opens bot picker", async ({ page }) => {
const stamp = Date.now(); const stamp = Date.now();
await signup(page, `unterhaltungen-${stamp}@rakazo.test`, "password12", "Unterhaltungen UX"); await signup(page, `unterhaltungen-${stamp}@rakazo.test`, "password12", "Unterhaltungen UX");
await completeOnboarding(page); await completeOnboarding(page);
@ -11,8 +11,8 @@ test("Unterhaltungen and Projekte stay separate; + opens bot picker", async ({ p
const unterhaltungen = page.locator(".sidebar-unterhaltungen"); const unterhaltungen = page.locator(".sidebar-unterhaltungen");
const projekte = page.locator(".sidebar-projekte"); const projekte = page.locator(".sidebar-projekte");
await expect(unterhaltungen.getByText("Sprich mit deinen Oo's", { exact: true })).toBeVisible(); await expect(unterhaltungen.getByText("Talk to your Oos", { exact: true })).toBeVisible();
await expect(projekte.getByText("Projekte", { exact: true })).toBeVisible(); await expect(projekte.getByText("Projects", { exact: true })).toBeVisible();
await expect(unterhaltungen.getByRole("button", { name: /^Chief/ })).toBeVisible(); await expect(unterhaltungen.getByRole("button", { name: /^Chief/ })).toBeVisible();
await unterhaltungen.getByTestId("conversation-picker-trigger").click(); await unterhaltungen.getByTestId("conversation-picker-trigger").click();

View File

@ -0,0 +1,74 @@
/**
* E2E: Settings → About you edits a fact in place: its wording, category and the spaces it
* shows in.
*/
import { expect, test } from "@playwright/test";
import type { UserProfileState } from "@rakazo/contracts";
import { captureScreenshot, completeOnboarding, openUserSettings, rpc, signup } from "./helpers";
test("a fact's wording, category and spaces are edited in place", async ({ page }, testInfo) => {
const stamp = Date.now();
await signup(page, `profile-edit-${stamp}@rakazo.test`, "password12", "Edit Tester");
await completeOnboarding(page);
const side = await rpc<{ id: string }>(page, "spaces/create", { name: "Side project" });
await rpc(page, "profile/update", {
addFacts: [{ category: "preferences", value: "Prefers tea" }],
});
const settings = await openUserSettings(page, "profile");
const profile = settings.getByTestId("user-profile-settings");
const tea = profile.getByTestId("user-fact").filter({ hasText: "Prefers tea" });
await tea.getByRole("button", { name: "Edit" }).click();
const editor = profile.getByTestId("user-fact-editor");
const wording = editor.getByRole("textbox", { name: "Fact" });
await expect(wording).toBeFocused();
await wording.fill("Prefers green tea");
await editor.getByRole("combobox", { name: "Category" }).selectOption("habits");
await editor.getByRole("button", { name: "All spaces" }).click();
await page.getByRole("menuitemcheckbox", { name: "Side project" }).click();
await expect(page.getByRole("menuitemcheckbox", { name: "All spaces" })).not.toBeChecked();
await page.keyboard.press("Escape");
await expect(editor.getByRole("button", { name: "Side project" })).toBeVisible();
await captureScreenshot(page, testInfo, "user-profile-edit");
await editor.getByRole("button", { name: "Save", exact: true }).click();
await expect(editor).toHaveCount(0);
const edited = profile
.getByTestId("user-facts-habits")
.getByTestId("user-fact")
.filter({ hasText: "Prefers green tea" });
await expect(edited).toContainText("Side project");
await expect(tea).toHaveCount(0);
const teaFact = async () =>
(await rpc<UserProfileState>(page, "profile/get", null)).facts.find((fact) =>
fact.value.includes("tea"),
);
expect(await teaFact()).toMatchObject({
value: "Prefers green tea",
category: "habits",
allSpaces: false,
spaceIds: [side.id],
});
// Escape leaves the fact as it was; showing it everywhere again clears the limit.
await edited.getByRole("button", { name: "Edit" }).click();
await editor.getByRole("textbox", { name: "Fact" }).fill("Discarded");
await page.keyboard.press("Escape");
await expect(editor).toHaveCount(0);
await expect(edited).toContainText("Prefers green tea");
await expect(settings).toBeVisible();
await edited.getByRole("button", { name: "Edit" }).click();
await editor.getByRole("button", { name: "Side project" }).click();
await page.getByRole("menuitemcheckbox", { name: "All spaces" }).click();
await page.keyboard.press("Escape");
await editor.getByRole("button", { name: "Save", exact: true }).click();
await expect(editor).toHaveCount(0);
await expect(edited).not.toContainText("Side project");
expect(await teaFact()).toMatchObject({
value: "Prefers green tea",
allSpaces: true,
spaceIds: [],
});
});

View File

@ -1,6 +1,7 @@
/** /**
* E2E: onboarding answers land in the user profile, and Settings → About you shows every * E2E: onboarding fills the user profile without asking (time zone, locale and language from
* fact with its origin, confirms a pending one, and forgets one or all of them. * the browser) and records the focus answer as a fact; Settings → About you shows every fact
* with its origin, confirms a pending one, and forgets one or all of them.
*/ */
import { expect, test } from "@playwright/test"; import { expect, test } from "@playwright/test";
import { captureScreenshot, openUserSettings, rpc, signup, waitForChatComposer } from "./helpers"; import { captureScreenshot, openUserSettings, rpc, signup, waitForChatComposer } from "./helpers";
@ -8,7 +9,7 @@ import { captureScreenshot, openUserSettings, rpc, signup, waitForChatComposer }
test.use({ timezoneId: "Europe/Vienna" }); test.use({ timezoneId: "Europe/Vienna" });
test.beforeEach(async ({ page }) => { test.beforeEach(async ({ page }) => {
// Walk the full emoji-first flow, including the preferences step. // Walk the full emoji-first flow.
await page.route("**/rpc/integrationSetup/get", (route) => await page.route("**/rpc/integrationSetup/get", (route) =>
route.fulfill({ route.fulfill({
json: { json: {
@ -34,27 +35,23 @@ test("onboarding feeds the profile; facts can be confirmed and forgotten", async
}); });
await page.getByRole("button", { name: "Continue", exact: true }).click(); await page.getByRole("button", { name: "Continue", exact: true }).click();
await expect(page.getByRole("heading", { name: /Tell us about yourself/i })).toBeVisible();
await expect(page.getByLabel("Timezone")).toHaveValue("Europe/Vienna");
await page.getByPlaceholder("Your name").fill("Pat Example");
await page.getByRole("button", { name: "Music", exact: true }).click();
await page.getByRole("button", { name: "Deep-dive focused" }).click();
await page.getByRole("button", { name: "Direct & concise" }).click();
await page.getByRole("button", { name: "Continue", exact: true }).click();
await expect(page.getByRole("heading", { name: /Choose your language/i })).toBeVisible(); await expect(page.getByRole("heading", { name: /Choose your language/i })).toBeVisible();
await page.getByRole("button", { name: "English", exact: true }).click(); await page.getByRole("button", { name: "English", exact: true }).click();
await expect(page.getByRole("heading", { name: /Pick a voice/i })).toBeVisible(); await expect(page.getByRole("heading", { name: /Pick a voice/i })).toBeVisible();
await page.getByRole("button", { name: "Skip", exact: true }).click(); await page.getByRole("button", { name: "Skip", exact: true }).click();
await page.waitForURL(/\/app\/[^/]+/, { timeout: 30_000 }); await page.waitForURL(/\/app\/g\/[^/]+/, { timeout: 30_000 });
await waitForChatComposer(page); await waitForChatComposer(page);
// The main bot asks the rest in the main conversation; the focus answer becomes a fact.
await page.getByTestId("focus-choice-day").click();
await expect(page.locator('[data-testid="choice-card"][data-step="goal"]')).toBeVisible();
// The answers no longer overwrite a bot's instructions. // The answers no longer overwrite a bot's instructions.
const bots = await rpc<Array<{ instructions: string }>>(page, "bots/list", null); const bots = await rpc<Array<{ instructions: string }>>(page, "bots/list", null);
expect(bots.length).toBeGreaterThan(0); expect(bots.length).toBeGreaterThan(0);
for (const bot of bots) expect(bot.instructions).not.toContain("User Context"); for (const bot of bots) expect(bot.instructions).not.toContain("User Context");
expect((await rpc<{ name: string }>(page, "me", null)).name).toBe("Pat Example"); // The account name is used as is; onboarding no longer asks for it again.
expect((await rpc<{ name: string }>(page, "me", null)).name).toBe("Profile Tester");
// Sensitive facts from anywhere but the user wait for confirmation. // Sensitive facts from anywhere but the user wait for confirmation.
await rpc(page, "profile/update", { await rpc(page, "profile/update", {
@ -67,14 +64,13 @@ test("onboarding feeds the profile; facts can be confirmed and forgotten", async
await expect(profile.getByTestId("profile-field-timeZone")).toContainText("Europe/Vienna"); await expect(profile.getByTestId("profile-field-timeZone")).toContainText("Europe/Vienna");
await expect(profile.getByTestId("profile-field-language")).toContainText("English"); await expect(profile.getByTestId("profile-field-language")).toContainText("English");
await expect(profile.getByTestId("profile-field-locale")).toContainText("United States"); await expect(profile.getByTestId("profile-field-locale")).toContainText("United States");
await expect(profile.getByTestId("profile-field-communicationStyle")).toContainText(
"Direct and concise",
);
const music = profile.getByTestId("user-fact").filter({ hasText: "Interested in music" }); const focus = profile
await expect(music).toContainText("Onboarding"); .getByTestId("user-fact")
await expect(profile.getByTestId("user-facts-habits")).toContainText( .filter({ hasText: "Wants help with day-to-day work first" });
"Prefers focused deep dives", await expect(focus).toContainText("Onboarding");
await expect(profile.getByTestId("user-facts-goals")).toContainText(
"Wants help with day-to-day work first",
); );
const allergy = profile.getByTestId("user-fact").filter({ hasText: "Allergic to peanuts" }); const allergy = profile.getByTestId("user-fact").filter({ hasText: "Allergic to peanuts" });
@ -84,8 +80,8 @@ test("onboarding feeds the profile; facts can be confirmed and forgotten", async
await expect(allergy).toHaveAttribute("data-status", "active"); await expect(allergy).toHaveAttribute("data-status", "active");
await expect(allergy.getByRole("button", { name: "Confirm" })).toHaveCount(0); await expect(allergy.getByRole("button", { name: "Confirm" })).toHaveCount(0);
await music.getByRole("button", { name: "Delete" }).click(); await focus.getByRole("button", { name: "Delete" }).click();
await expect(music).toHaveCount(0); await expect(focus).toHaveCount(0);
await captureScreenshot(page, testInfo, "user-profile-settings"); await captureScreenshot(page, testInfo, "user-profile-settings");
const afterDelete = await rpc<{ facts: Array<{ value: string; status: string }> }>( const afterDelete = await rpc<{ facts: Array<{ value: string; status: string }> }>(
page, page,
@ -93,7 +89,9 @@ test("onboarding feeds the profile; facts can be confirmed and forgotten", async
null, null,
); );
expect(afterDelete.facts).toEqual( expect(afterDelete.facts).toEqual(
expect.not.arrayContaining([expect.objectContaining({ value: "Interested in music" })]), expect.not.arrayContaining([
expect.objectContaining({ value: "Wants help with day-to-day work first" }),
]),
); );
expect(afterDelete.facts).toContainEqual( expect(afterDelete.facts).toContainEqual(
expect.objectContaining({ value: "Allergic to peanuts", status: "active" }), expect.objectContaining({ value: "Allergic to peanuts", status: "active" }),

View File

@ -9,6 +9,7 @@
<link rel="icon" href="/favicon.ico" sizes="any" /> <link rel="icon" href="/favicon.ico" sizes="any" />
<link rel="icon" href="/favicon.svg" type="image/svg+xml" /> <link rel="icon" href="/favicon.svg" type="image/svg+xml" />
<link rel="apple-touch-icon" href="/apple-touch-icon.png" /> <link rel="apple-touch-icon" href="/apple-touch-icon.png" />
<link rel="manifest" href="/site.webmanifest" />
<!-- Fonts loaded locally via @fontsource-variable (Inter + Geist) — no external CDN --> <!-- Fonts loaded locally via @fontsource-variable (Inter + Geist) — no external CDN -->
<link rel="stylesheet" href="/evoo-brand.css" /> <link rel="stylesheet" href="/evoo-brand.css" />
<title>evOo</title> <title>evOo</title>
@ -26,7 +27,7 @@
document.documentElement.dataset.theme = theme; document.documentElement.dataset.theme = theme;
document.documentElement.style.colorScheme = theme; document.documentElement.style.colorScheme = theme;
const meta = document.querySelector('meta[name="theme-color"]'); const meta = document.querySelector('meta[name="theme-color"]');
if (meta) meta.setAttribute("content", theme === "light" ? "#fafaf8" : "#07080a"); if (meta) meta.setAttribute("content", theme === "light" ? "#ffffff" : "#07080a");
})(); })();
</script> </script>
<script> <script>

View File

@ -1,4 +1,28 @@
import type { CatalogFormatter, CatalogType } from "@lingui/conf";
import { defineConfig } from "@lingui/conf"; import { defineConfig } from "@lingui/conf";
import { formatter } from "@lingui/format-po";
// File references without line numbers, so catalogs change only when messages change.
const po = formatter({ lineNumbers: false });
const collator = new Intl.Collator("en-US");
// Lingui orders entries by source text but leaves ties (explicit ids, equal text) in the order the
// files were found, which differs between file systems. Break ties by id so output is stable.
function sortCatalog(catalog: CatalogType): CatalogType {
return Object.fromEntries(
Object.entries(catalog).sort(
([aId, a], [bId, b]) =>
collator.compare(a.message ?? aId, b.message ?? bId) ||
collator.compare(a.context ?? "", b.context ?? "") ||
(aId < bId ? -1 : aId > bId ? 1 : 0),
),
);
}
const stablePo: CatalogFormatter = {
...po,
serialize: (catalog, ctx) => po.serialize(sortCatalog(catalog), ctx),
};
export default defineConfig({ export default defineConfig({
sourceLocale: "en", sourceLocale: "en",
@ -10,5 +34,6 @@ export default defineConfig({
exclude: ["**/locales/**", "**/*.test.*"], exclude: ["**/locales/**", "**/*.test.*"],
}, },
], ],
format: stablePo,
compileNamespace: "es", compileNamespace: "es",
}); });

View File

@ -36,6 +36,7 @@
"@lingui/babel-plugin-lingui-macro": "6.6.0", "@lingui/babel-plugin-lingui-macro": "6.6.0",
"@lingui/cli": "6.6.0", "@lingui/cli": "6.6.0",
"@lingui/conf": "6.6.0", "@lingui/conf": "6.6.0",
"@lingui/format-po": "6.6.0",
"@lingui/vite-plugin": "6.6.0", "@lingui/vite-plugin": "6.6.0",
"@playwright/test": "1.63.0", "@playwright/test": "1.63.0",
"@rolldown/plugin-babel": "0.2.4", "@rolldown/plugin-babel": "0.2.4",

View File

@ -1,6 +1,6 @@
{ {
"name": "EvOo", "name": "evOo",
"short_name": "EvOo", "short_name": "evOo",
"description": "Deine Oos — immer da, immer wach.", "description": "Deine Oos — immer da, immer wach.",
"start_url": "/", "start_url": "/",
"display": "standalone", "display": "standalone",

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