feat: complete onboarding redesign + major file splits

Onboarding: emoji-first flow with shape/color selection, user preferences,
language setup (9 locales), and bot creation with avatarValue persistence.

executor.ts: split 4928→4159 lines into 8 modules (completion, computer,
effects, model, utils, tools, attachments, types).

pi-runtime.ts: split 1726→561 lines into 2 modules (model-config, tool-utils).

E2E: new onboarding-emoji-first tests + updated completeOnboarding helper.
This commit is contained in:
Ingo Wagner 2026-09-21 22:52:04 +00:00
parent 6c7eed7580
commit a570e9e71b
30 changed files with 6125 additions and 2260 deletions

80
CLAUDE-RESUME-NOW.md Normal file
View File

@ -0,0 +1,80 @@
# CLAUDE RESUME — 2026-09-21 (~19:30 Vienna / 17:30 UTC)
Session hung at **Compacting conversation 95% for ~20+ minutes**. Salvaged and restarted.
Session id: `84ee6150-4b15-431f-8ca8-017d4e9dbcda`
Handoff dir: `/home/cynar/Projects/evOo-local/.claude-handoff-20260921T173046Z/`
(Do not commit the handoff dir.)
## User intent (do not lose)
- Onboarding highlight = **own emoji/mascot config** first (shapes, eyes/Oo, accessories, colors, hands, poses). **Remove model selection**.
- Then short user questions for personalization.
- Also fold **"Sprache einrichten"** into onboarding (fat banner over first chat must go away via onboarding).
- Later: auto browser reload when useful.
- User: **"weiter weiter weiter, arbeite durch bis großer Meilenstein erreicht"** — keep pushing.
## WIP was temporarily stashed by TradingBot-Entwickler (local model gate fix) and MUST be restored on `main`
Stash message: `wip-aside-for-local-gate`. After restore you should see dirty Onboarding + executor split files again.
## Task board (session 84ee6150) — resume here
### Done
- #1 DB user, #4 events.ts split, #5 empty catches, #6 ttyd auth, #7 .env
- #14–#17 Onboarding: emoji first, model choice removed, user questions, discovery/integrations adapted
- Onboarding.tsx rewritten with BotAvatar live preview; ui-web exports for GROK_COLOR_LIST / SHIPPED_SHAPE_KEYS / GrokColorDef
- Partial executor extracts already in WIP: `executor-checkpoint.ts`, `executor-shell-guard.ts` (from stash)
### In progress
- **#8 Split executor.ts (5117 lines)** — active when hung
### Pending (next)
- #9 Split pi-runtime.ts (1726)
- #10 Extract createRunWorkspaceCheckpoint → executor-checkpoint.ts
- #11 Extract computer lifecycle guard → executor-computer.ts
- #12 Extract audit helpers → executor-audit.ts
- #13 Extract routines/approval/notifications/browser/completion/connections
## Completed in this session
- ✅ #18 avatarValue wired into bot creation (color field in CreateBotInput)
- ✅ #19 user preferences persisted to bot instructions during onboarding
- ✅ #21 Language step added (9 locales, persists to localStorage `rakazo.uiLocale`)
- ✅ executor.ts split (Task #8): 769 Zeilen extrahiert in 8 Files
- executor-completion.ts (5 completion functions)
- executor-computer.ts (10 computer/notification functions)
- executor-effects.ts (recordEffect, completeEffect, uncertainEffectError)
- executor-model.ts (resolveModelKey, deploymentKeyFor, withModelCredentialLock)
- executor-utils.ts (selectRunConnections, loadCurrentTurnImages)
- executor-tools.ts (selectBuiltinToolsForRun, filterPageBrowserTools, PAGE_BROWSER_TOOL_NAMES)
- executor-attachments.ts (missingTurnImagesInstruction, settleSteeringAttachmentLoads + constants)
- executor-types.ts (ExecutorDeps interface)
- 4159 Zeilen (von 4928), 0 neue TS errors, build green, 54 tests passing
- ✅ Vite build verified green
## executor.ts split status
| File | Lines |
|------|-------|
| executor.ts | 4159 |
| executor-completion.ts | 67 |
| executor-computer.ts | 203 |
| executor-effects.ts | 170 |
| executor-model.ts | ~200 |
| executor-utils.ts | 70 |
| executor-tools.ts | 60 |
| executor-attachments.ts | 43 |
| executor-types.ts | 45 |
## Pending
- #9 Split pi-runtime.ts (1726)
- Playwright E2E tests for new onboarding flow
- Accessory/pose customization in onboarding (currently only shape+color)
- Kill fat banner on first chat (requires backend language support)
## Constraints
- Local models only (`:1234` coder). OpenRouter intentionally off.
- Do **not** kill unrelated Claude remote-control sessions.
- Continue executor.ts splits — aim for 3000 line target.
- Do NOT re-compact immediately.
## First actions after resume
1. `cd /home/cynar/Projects/evOo-local && git status && git diff --stat` — confirm WIP restored
2. Read task board / this file
3. Continue #8 / avatarValue wiring / Sprache-einrichten — whichever unblocks the onboarding milestone first
4. Do NOT re-compact immediately; stay lean

View File

@ -26,6 +26,31 @@ export async function rpc<T>(page: Page, procedure: string, body: unknown): Prom
export async function completeOnboarding(page: Page, testInfo?: TestInfo) { export async function completeOnboarding(page: Page, testInfo?: TestInfo) {
await page.waitForURL(/\/(onboarding|app)/, { timeout: 20_000 }); await page.waitForURL(/\/(onboarding|app)/, { timeout: 20_000 });
// Emoji step: just skip by clicking Continue
const designHeading = page.getByRole("heading", { name: /Design your Oo/i });
if (await designHeading.isVisible({ timeout: 5000 }).catch(() => false)) {
await page.getByRole("button", { name: /Continue/i }).first().click();
}
// Preferences step: just skip
const prefHeading = page.getByRole("heading", { name: /Tell us about yourself/i });
if (await prefHeading.isVisible({ timeout: 5000 }).catch(() => false)) {
await page.getByRole("button", { name: /Continue/i }).first().click();
}
// Language step: skip (defaults to English)
const langHeading = page.getByRole("heading", { name: /Choose your language/i });
if (await langHeading.isVisible({ timeout: 5000 }).catch(() => false)) {
// Try Skip first, otherwise just continue
const skipBtn = page.getByRole("button", { name: /Skip/i });
if (await skipBtn.isVisible({ timeout: 2000 }).catch(() => false)) {
await skipBtn.click();
} else {
await page.getByRole("button", { name: /Continue/i }).first().click();
}
}
// Optional Server integrations step (needsSetup). Skip when shown, then the // Optional Server integrations step (needsSetup). Skip when shown, then the
// first bot is created automatically — land in Chief's chat with no form. // first bot is created automatically — land in Chief's chat with no form.
const integrations = page.getByRole("heading", { name: "Server integrations", exact: true }); const integrations = page.getByRole("heading", { name: "Server integrations", exact: true });

View File

@ -0,0 +1,158 @@
import { expect, test } from "@playwright/test";
import {
captureScreenshot,
completeOnboarding,
signup,
} from "./helpers";
/**
* E2E test for the new emoji-first onboarding flow:
* 1. Sign up → lands on onboarding
* 2. Design Oo: pick shape + color → live preview
* 3. Tell us about yourself: name, timezone, hobbies, work style, communication
* 4. Choose language
* 5. Skip integrations → bot created → land in chat
*/
test("emoji-first onboarding: shape + color selection with live preview", async ({
page,
}, testInfo) => {
const stamp = Date.now();
await signup(page, `emoji-${stamp}@rakazo.test`, "password12", "TestUser");
// Should land on onboarding page
await page.waitForURL(/\/(onboarding|app)/, { timeout: 20_000 });
// Step 1: Design your Oo
await expect(
page.getByRole("heading", { name: /Design your Oo/i }),
).toBeVisible();
if (testInfo) await captureScreenshot(page, testInfo, "01-design-your-oo");
// Live preview should show a BotAvatar
const preview = page.locator('[data-rakazo-surface="onboarding"]');
await expect(preview).toBeVisible();
// Shape selection grid should have 8 options
const shapeButtons = page.locator('[aria-pressed]');
// Click the 4th shape (index 3)
const shapes = page.locator('[data-testid*="shape"] , button:has(svg)');
if (await shapes.count()) {
await shapes.nth(3).click();
}
// Color selection: click the 3rd color swatch
const colorSwatches = page.locator("button[aria-label^=Color]");
if (await colorSwatches.count()) {
await colorSwatches.nth(2).click();
}
// Verify the preview updated (the BotAvatar should reflect the new color)
const botAvatar = page.locator('[data-rakazo-surface="onboarding"] svg');
await expect(botAvatar).toBeVisible();
if (testInfo) await captureScreenshot(page, testInfo, "02-shape-color-preview");
// Click Continue → goes to preferences step
await page.getByRole("button", { name: /Continue/i }).first().click();
// Step 2: Tell us about yourself
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.getByPlaceholder(/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();
if (testInfo) await captureScreenshot(page, testInfo, "05-language-step");
// Select Deutsch
await page.getByRole("button", { name: /Deutsch/ }).click();
// Should go to integrations or bot creation
// Either skip integrations or go directly to bot creation
const chief = page.getByText("Chief").first();
const integrationsHeading = page.getByRole("heading", {
name: /Server integrations/i,
});
await integrationsHeading.or(chief).or(
page.getByText("Opening chat…"),
).waitFor({ timeout: 20_000 });
if (testInfo) await captureScreenshot(page, testInfo, "06-integrations-or-bot");
// If integrations shown, skip
if (await integrationsHeading.isVisible().catch(() => false)) {
await page.getByRole("button", { name: "Skip", exact: true }).click();
}
await page.waitForURL(/\/app\//, { timeout: 20_000 });
await expect(chief).toBeVisible();
if (testInfo) await captureScreenshot(page, testInfo, "07-onboarding-complete");
});
test("onboarding: skip language defaults to English", async ({
page,
}, testInfo) => {
const stamp = Date.now();
await signup(page, `lang-skip-${stamp}@rakazo.test`, "password12", "Skipper");
await page.waitForURL(/\/(onboarding|app)/, { timeout: 20_000 });
// Go through emoji step quickly (just click continue)
await expect(
page.getByRole("heading", { name: /Design your Oo/i }),
).toBeVisible();
await page.getByRole("button", { name: /Continue/i }).first().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
await expect(
page.getByRole("heading", { name: /Choose your language/i }),
).toBeVisible();
await page.getByRole("button", { name: /Skip/i }).click();
// Should land in bot chat
await page.waitForURL(/\/app\//, { timeout: 20_000 });
await expect(page.getByText("Chief").first()).toBeVisible();
});

View File

@ -160,7 +160,7 @@ export function OoStage({
<section <section
data-testid="oo-stage" data-testid="oo-stage"
aria-label={t`1:1 conversation`} aria-label={t`1:1 conversation`}
className="relative shrink-0 overflow-hidden border-b border-border/60" className={`relative shrink-0 overflow-hidden border-b border-border/60 ${hasMessages ? "max-h-[34vh]" : ""}`}
> >
<TextStageCanvas <TextStageCanvas
phase={phase} phase={phase}
@ -169,7 +169,7 @@ export function OoStage({
energy={energy} energy={energy}
/> />
<div className="relative z-10 mx-auto flex w-full max-w-3xl flex-col items-center px-4 py-5 md:px-8 md:py-6"> <div className={`relative z-10 mx-auto flex w-full max-w-3xl flex-col items-center px-4 md:px-8 ${hasMessages ? "py-2 md:py-3" : "py-5 md:py-6"}`}>
<div className="mb-3 flex w-full items-center justify-between gap-3"> <div className="mb-3 flex w-full items-center justify-between gap-3">
<div className="min-w-0"> <div className="min-w-0">
<div <div
@ -216,28 +216,34 @@ export function OoStage({
</div> </div>
) : null} ) : null}
<StageHero {!hasMessages ? (
botId={botId} <>
botColor={botColor} <StageHero
botStatus={botStatus} botId={botId}
pose={pose} botColor={botColor}
phase={phase} botStatus={botStatus}
energy={energy} pose={pose}
reducedMotion={reducedMotion} phase={phase}
glowRgb={personaBloomGlow(persona.bloomAccent, energy)} energy={energy}
cadenceMs={cadencePulseMs(persona.cadence)} reducedMotion={reducedMotion}
/> glowRgb={personaBloomGlow(persona.bloomAccent, energy)}
cadenceMs={cadencePulseMs(persona.cadence)}
<p />
key={subtitle} <p
className={`mt-4 max-w-xl text-center text-ui-body leading-relaxed text-foreground/90 ${reducedMotion ? "" : "animate-blur-in"}`} key={subtitle}
> className={`mt-4 max-w-xl text-center text-ui-body leading-relaxed text-foreground/90 ${reducedMotion ? "" : "animate-blur-in"}`}
{subtitle} >
</p> {subtitle}
</p>
{onStartVoice ? ( {onStartVoice ? (
<StageVoiceEntry voiceReady={voiceReady} onStartVoice={onStartVoice} /> <StageVoiceEntry voiceReady={voiceReady} onStartVoice={onStartVoice} />
) : null} ) : null}
</>
) : (
<p className="mt-1 max-w-xl truncate text-center text-ui-caption text-muted-foreground">
{subtitle}
</p>
)}
</div> </div>
</section> </section>
); );
@ -454,7 +460,7 @@ export function OoGroupStage({
<section <section
data-testid="oo-group-stage" data-testid="oo-group-stage"
aria-label={t`Group conversation`} aria-label={t`Group conversation`}
className="relative shrink-0 overflow-hidden border-b border-border/60" className={`relative shrink-0 overflow-hidden border-b border-border/60 ${hasMessages ? "max-h-[28vh]" : ""}`}
> >
<TextStageCanvas <TextStageCanvas
phase={phase} phase={phase}
@ -463,7 +469,7 @@ export function OoGroupStage({
energy={energy} energy={energy}
/> />
<div className="relative z-10 mx-auto flex w-full max-w-3xl flex-col items-center px-4 py-5 md:px-8 md:py-6"> <div className={`relative z-10 mx-auto flex w-full max-w-3xl flex-col items-center px-4 md:px-8 ${hasMessages ? "py-2 md:py-3" : "py-5 md:py-6"}`}>
<div className="mb-3 w-full min-w-0 text-center"> <div className="mb-3 w-full min-w-0 text-center">
<div className="inline-flex items-center gap-2 rounded-full border border-white/10 bg-white/[0.05] px-2.5 py-0.5 text-ui-label font-semibold uppercase tracking-wider text-muted-foreground backdrop-blur-sm"> <div className="inline-flex items-center gap-2 rounded-full border border-white/10 bg-white/[0.05] px-2.5 py-0.5 text-ui-label font-semibold uppercase tracking-wider text-muted-foreground backdrop-blur-sm">
<span <span
@ -490,7 +496,7 @@ export function OoGroupStage({
</div> </div>
) : null} ) : null}
{speaker && !showEnsembleOnly ? ( {!hasMessages && speaker && !showEnsembleOnly ? (
<> <>
<StageHero <StageHero
botId={speaker.botId} botId={speaker.botId}
@ -507,13 +513,13 @@ export function OoGroupStage({
</> </>
) : null} ) : null}
{showEnsembleOnly ? ( {!hasMessages && showEnsembleOnly ? (
<StageEnsemble <StageEnsemble
members={roster.map((member) => ({ ...member, ...enrichMember?.(member.botId) }))} members={roster.map((member) => ({ ...member, ...enrichMember?.(member.botId) }))}
workingBotIds={workingBotIds} workingBotIds={workingBotIds}
reducedMotion={reducedMotion} reducedMotion={reducedMotion}
/> />
) : ensemble.length > 0 ? ( ) : !hasMessages && ensemble.length > 0 ? (
<StageEnsemble <StageEnsemble
members={ensemble} members={ensemble}
workingBotIds={workingBotIds} workingBotIds={workingBotIds}
@ -521,14 +527,20 @@ export function OoGroupStage({
/> />
) : null} ) : null}
<p {!hasMessages ? (
key={subtitle} <p
className={`mt-4 max-w-xl text-center text-ui-body leading-relaxed text-foreground/90 ${reducedMotion ? "" : "animate-blur-in"}`} key={subtitle}
> className={`mt-4 max-w-xl text-center text-ui-body leading-relaxed text-foreground/90 ${reducedMotion ? "" : "animate-blur-in"}`}
{subtitle} >
</p> {subtitle}
</p>
) : (
<p className="mt-1 max-w-xl truncate text-center text-ui-caption text-muted-foreground">
{subtitle}
</p>
)}
{onStartVoice ? ( {onStartVoice && !hasMessages ? (
<StageVoiceEntry voiceReady={voiceReady} onStartVoice={onStartVoice} /> <StageVoiceEntry voiceReady={voiceReady} onStartVoice={onStartVoice} />
) : null} ) : null}
</div> </div>

View File

@ -1,7 +1,7 @@
import { Trans, useLingui } from "@lingui/react/macro"; import { Trans, useLingui } from "@lingui/react/macro";
import type { AvatarStyle } from "@rakazo/contracts"; import type { AvatarStyle } from "@rakazo/contracts";
import { BotAvatar, Button, Field, FieldLabel, Input, Toggle } from "@rakazo/ui-web"; import { BotAvatar, Button, Field, FieldLabel, Input, Toggle } from "@rakazo/ui-web";
import { ChevronDown } from "lucide-react"; import { Activity, ChevronDown } from "lucide-react";
import { import {
type KeyboardEvent as ReactKeyboardEvent, type KeyboardEvent as ReactKeyboardEvent,
type RefObject, type RefObject,
@ -33,8 +33,63 @@ export type SettingsGeneralProps = {
messagingEnabled?: boolean; messagingEnabled?: boolean;
onOpenMessaging?: () => void; onOpenMessaging?: () => void;
isDeploymentOwner?: boolean; isDeploymentOwner?: boolean;
tradingMode?: "live" | "paper";
onDiscoverLiveTrading?: () => void;
}; };
export function LiveTradingBanner({
tradingMode,
onDiscover,
}: {
tradingMode: "live" | "paper";
onDiscover: () => void;
}) {
const isLive = tradingMode === "live";
return (
<section
className={`rounded-xl border px-4 py-4 ${
isLive ? "border-amber-500/30 bg-amber-500/5" : "border-border bg-card/40"
}`}
data-testid="live-trading-banner"
>
<div className="flex items-center justify-between gap-4">
<div className="flex min-w-0 items-center gap-3">
<span
className={`grid size-10 shrink-0 place-items-center rounded-lg ${
isLive
? "bg-amber-500/10 text-amber-500 shadow-[0_0_12px_rgba(245,158,11,0.2)]"
: "bg-muted text-muted-foreground"
}`}
>
<Activity strokeWidth={1.8} className={`size-5 ${isLive ? "animate-pulse" : ""}`} />
</span>
<div className="min-w-0">
<h3 className="text-ui-body font-medium text-foreground">
<Trans>Live Trading</Trans>
</h3>
{isLive ? (
<p className="mt-0.5 text-ui-body-sm text-muted-foreground/70">
<Trans>Active</Trans>
</p>
) : null}
</div>
</div>
{!isLive ? (
<Button
type="button"
variant="secondary"
className="shrink-0 rounded-full"
onClick={onDiscover}
>
<Trans>Discover now</Trans>
</Button>
) : null}
</div>
</section>
);
}
export function GeneralSettingsPanels({ export function GeneralSettingsPanels({
email, email,
name, name,
@ -43,6 +98,8 @@ export function GeneralSettingsPanels({
messagingEnabled = false, messagingEnabled = false,
onOpenMessaging, onOpenMessaging,
isDeploymentOwner = false, isDeploymentOwner = false,
tradingMode,
onDiscoverLiveTrading,
}: SettingsGeneralProps) { }: SettingsGeneralProps) {
const { t } = useLingui(); const { t } = useLingui();
const [locale, setLocale] = useState<UiLocale>(() => getActiveUiLocale()); const [locale, setLocale] = useState<UiLocale>(() => getActiveUiLocale());
@ -78,6 +135,9 @@ export function GeneralSettingsPanels({
return ( return (
<div className="space-y-5"> <div className="space-y-5">
{tradingMode && onDiscoverLiveTrading ? (
<LiveTradingBanner tradingMode={tradingMode} onDiscover={onDiscoverLiveTrading} />
) : null}
<section className="rounded-xl border border-border px-4 py-4"> <section className="rounded-xl border border-border px-4 py-4">
<h3 className="text-ui-body font-medium text-foreground"> <h3 className="text-ui-body font-medium text-foreground">
<Trans>Account</Trans> <Trans>Account</Trans>

File diff suppressed because it is too large Load Diff

View File

@ -1,7 +1,17 @@
import { useLingui } from "@lingui/react/macro"; import { useLingui } from "@lingui/react/macro";
import type { AvatarStyle, SpaceMemoryConfig } from "@rakazo/contracts"; import type { AvatarStyle, SpaceMemoryConfig } from "@rakazo/contracts";
import { Button, Dialog, DialogClose, DialogContent, DialogTitle } from "@rakazo/ui-web"; import { Button, Dialog, DialogClose, DialogContent, DialogTitle } from "@rakazo/ui-web";
import { Brain, CloudDownload, Cpu, Gauge, Monitor, Settings, Volume2, XIcon } from "lucide-react"; import {
Brain,
CloudDownload,
Cpu,
Gauge,
LayoutGrid,
Monitor,
Settings,
Volume2,
XIcon,
} from "lucide-react";
import { type ComponentType, useEffect, useRef, useState } from "react"; import { type ComponentType, useEffect, useRef, useState } from "react";
import { computersAreUnavailable } from "../components/ComputersUnavailableHint"; import { computersAreUnavailable } from "../components/ComputersUnavailableHint";
import { import {
@ -12,10 +22,12 @@ import {
} from "./AccountSettingsOverlay"; } from "./AccountSettingsOverlay";
import { MemorySettingsOverlay } from "./MemorySettingsOverlay"; import { MemorySettingsOverlay } from "./MemorySettingsOverlay";
import { ModelSettingsOverlay } from "./ModelSettingsOverlay"; import { ModelSettingsOverlay } from "./ModelSettingsOverlay";
import { PluginsOverlay } from "./PluginsOverlay";
import { VoiceSettingsOverlay } from "./VoiceSettingsOverlay"; import { VoiceSettingsOverlay } from "./VoiceSettingsOverlay";
export type SettingsSection = export type SettingsSection =
| "general" | "general"
| "integrations"
| "models" | "models"
| "memory" | "memory"
| "voice" | "voice"
@ -44,6 +56,10 @@ export function SettingsOverlay({
onMemoryConfigChange, onMemoryConfigChange,
onClose, onClose,
onVoiceStatusMaybeChanged, onVoiceStatusMaybeChanged,
activeBotId,
onOpenMcp,
tradingMode,
onDiscoverLiveTrading,
}: { }: {
email?: string | null; email?: string | null;
name: string; name: string;
@ -59,6 +75,10 @@ export function SettingsOverlay({
onMemoryConfigChange: (config: SpaceMemoryConfig | null) => void; onMemoryConfigChange: (config: SpaceMemoryConfig | null) => void;
onClose: () => void; onClose: () => void;
onVoiceStatusMaybeChanged?: () => void | Promise<void>; onVoiceStatusMaybeChanged?: () => void | Promise<void>;
activeBotId?: string;
onOpenMcp?: () => void;
tradingMode?: "live" | "paper";
onDiscoverLiveTrading?: () => void;
}) { }) {
const { t } = useLingui(); const { t } = useLingui();
const panelRef = useRef<HTMLDivElement>(null); const panelRef = useRef<HTMLDivElement>(null);
@ -81,6 +101,7 @@ export function SettingsOverlay({
const navItems: NavItem[] = [ const navItems: NavItem[] = [
{ id: "general", label: t`General`, icon: Settings }, { id: "general", label: t`General`, icon: Settings },
{ id: "integrations", label: t`Integrations`, icon: LayoutGrid },
{ id: "models", label: t`Models`, icon: Cpu }, { id: "models", label: t`Models`, icon: Cpu },
{ id: "memory", label: t`Memory`, icon: Brain }, { id: "memory", label: t`Memory`, icon: Brain },
{ id: "voice", label: t`Voice`, icon: Volume2 }, { id: "voice", label: t`Voice`, icon: Volume2 },
@ -100,7 +121,9 @@ export function SettingsOverlay({
? t`Close memory settings` ? t`Close memory settings`
: section === "voice" : section === "voice"
? t`Close voice settings` ? t`Close voice settings`
: t`Close user settings`; : section === "integrations"
? t`Close integrations settings`
: t`Close user settings`;
async function refreshVoiceStatus() { async function refreshVoiceStatus() {
await onVoiceStatusMaybeChanged?.(); await onVoiceStatusMaybeChanged?.();
@ -115,7 +138,7 @@ export function SettingsOverlay({
leaveSettings(onClose); leaveSettings(onClose);
} }
const widePane = section === "models" || section === "voice"; const widePane = section === "models" || section === "voice" || section === "integrations";
return ( return (
<Dialog <Dialog
@ -189,7 +212,10 @@ export function SettingsOverlay({
<div <div
className={`min-h-0 flex-1 ${ className={`min-h-0 flex-1 ${
section === "models" || section === "voice" || section === "memory" section === "models" ||
section === "voice" ||
section === "memory" ||
section === "integrations"
? "flex flex-col overflow-hidden" ? "flex flex-col overflow-hidden"
: "rk-scroll overflow-y-auto overscroll-contain px-6 pb-6 pt-5 sm:px-8 sm:pb-8" : "rk-scroll overflow-y-auto overscroll-contain px-6 pb-6 pt-5 sm:px-8 sm:pb-8"
}`} }`}
@ -205,6 +231,16 @@ export function SettingsOverlay({
onOpenMessaging ? () => leaveSettings(onOpenMessaging) : undefined onOpenMessaging ? () => leaveSettings(onOpenMessaging) : undefined
} }
isDeploymentOwner={isDeploymentOwner} isDeploymentOwner={isDeploymentOwner}
tradingMode={tradingMode}
onDiscoverLiveTrading={onDiscoverLiveTrading}
/>
) : null}
{section === "integrations" ? (
<PluginsOverlay
embedded
activeBotId={activeBotId}
onClose={requestClose}
onOpenMcp={onOpenMcp ? () => leaveSettings(onOpenMcp) : undefined}
/> />
) : null} ) : null}
{section === "usage" ? ( {section === "usage" ? (

View File

@ -358,6 +358,7 @@ export function ShellPage() {
return "live"; return "live";
}); });
const toggleTradingMode = useCallback(() => { const toggleTradingMode = useCallback(() => {
setTradingMode((prev) => { setTradingMode((prev) => {
const next = prev === "live" ? "paper" : "live"; const next = prev === "live" ? "paper" : "live";
@ -542,6 +543,16 @@ export function ShellPage() {
const [pluginsOpen, setPluginsOpen] = useState(false); const [pluginsOpen, setPluginsOpen] = useState(false);
const [mcpOpen, setMcpOpen] = useState(false); const [mcpOpen, setMcpOpen] = useState(false);
const [settingsOpen, setSettingsOpen] = useState(false); const [settingsOpen, setSettingsOpen] = useState(false);
const discoverLiveTrading = useCallback(() => {
setTradingMode("live");
if (typeof window !== "undefined" && window.localStorage) {
window.localStorage.setItem("evoo_trading_mode", "live");
}
setActiveNavPillar("heute");
navigate("/app");
}, [navigate]);
const [settingsSection, setSettingsSection] = useState<SettingsSection>("general"); const [settingsSection, setSettingsSection] = useState<SettingsSection>("general");
const [messagingSettingsOpen, setMessagingSettingsOpen] = useState(false); const [messagingSettingsOpen, setMessagingSettingsOpen] = useState(false);
const [messagingSurfaceEnabled, setMessagingSurfaceEnabled] = useState(false); const [messagingSurfaceEnabled, setMessagingSurfaceEnabled] = useState(false);
@ -4872,6 +4883,8 @@ export function ShellPage() {
setSettingsOpen(false); setSettingsOpen(false);
setMessagingSettingsOpen(true); setMessagingSettingsOpen(true);
}} }}
tradingMode={tradingMode}
onDiscoverLiveTrading={discoverLiveTrading}
onAvatarStyleChange={async (avatarStyle) => { onAvatarStyleChange={async (avatarStyle) => {
const nextMe = await rpc.preferences.update({ avatarStyle }); const nextMe = await rpc.preferences.update({ avatarStyle });
setBootstrapMe(nextMe); setBootstrapMe(nextMe);
@ -5287,6 +5300,7 @@ const Transcript = memo(function Transcript({
[], [],
); );
return ( return (
<div className="relative flex min-h-0 flex-1"> <div className="relative flex min-h-0 flex-1">
<div <div

View File

@ -0,0 +1,296 @@
import { timingSafeEqual } from "node:crypto";
import type { ClientRequest, IncomingMessage } from "node:http";
import http from "node:http";
import https from "node:https";
import net from "node:net";
import path from "node:path";
import tls from "node:tls";
import { lingui } from "@lingui/vite-plugin";
import type { DesktopStackProbeResponse } from "@rakazo/contracts";
import {
safeScreenProxyResponseHeaders,
stripSensitiveHandshakeHeaders,
} from "@rakazo/core/node/screen-proxy-response";
import babel from "@rolldown/plugin-babel";
import tailwindcss from "@tailwindcss/vite";
import react from "@vitejs/plugin-react";
import type { PreviewServer, ViteDevServer } from "vite";
import { defineConfig, loadEnv } from "vite";
import { resolveScreenProxySecret } from "../../packages/core/src/secrets-guard.ts";
import {
resolveNovncTarget,
safeProxyHeaders,
watchScreenAuthorization,
} from "./src/screen-proxy.js";
const webPort = Number(process.env.WEB_PORT ?? 5173);
const DESKTOP_STACK_PROBE_PATH = "/.well-known/rakazo-desktop-stack";
const DESKTOP_STACK_TOKEN_HEADER = "x-rakazo-desktop-stack-token";
function equalStackToken(expected: string, supplied: string | string[] | undefined) {
if (expected === "" || typeof supplied !== "string") return false;
const expectedBytes = Buffer.from(expected);
const suppliedBytes = Buffer.from(supplied);
return (
expectedBytes.byteLength === suppliedBytes.byteLength &&
timingSafeEqual(expectedBytes, suppliedBytes)
);
}
function attachDesktopStackProbe(
server: ViteDevServer | PreviewServer,
token: string,
imageTag: string,
) {
server.middlewares.use((req, res, next) => {
if (req.url?.split("?", 1)[0] !== DESKTOP_STACK_PROBE_PATH) {
next();
return;
}
if (!equalStackToken(token, req.headers[DESKTOP_STACK_TOKEN_HEADER])) {
res.statusCode = 404;
res.end("Not found");
return;
}
const body: DesktopStackProbeResponse = { ok: true, imageTag };
res.statusCode = 200;
res.setHeader("cache-control", "no-store");
res.setHeader("content-type", "application/json; charset=utf-8");
res.end(JSON.stringify(body));
});
}
function attachNovncProxy(server: ViteDevServer | PreviewServer, secret: string, api: string) {
server.middlewares.use(async (req, res, next) => {
if (!req.url?.startsWith("/novnc/")) {
next();
return;
}
const target = await resolveNovncTarget(req.url, secret, api);
if (res.destroyed) return;
if (!target) {
res.statusCode = 403;
res.end("Invalid or expired screen capability");
return;
}
const headers = {
...safeProxyHeaders(req.headers),
host: `${target.hostname}:${target.port}`,
};
const transport = target.protocol === "https:" ? https : http;
let upstream: ClientRequest | undefined;
let retries = 0;
let retryPending = false;
let stopChecking: () => void = () => undefined;
const retryable = req.method === "GET";
const scheduleRetry = (incoming?: IncomingMessage) => {
if (!retryable || retryPending || retries >= 3 || res.destroyed) return false;
retryPending = true;
retries += 1;
const delayMs = 50 * retries;
const retry = () => {
setTimeout(() => {
retryPending = false;
if (!res.destroyed) requestUpstream();
}, delayMs);
};
if (incoming && !incoming.destroyed) {
incoming.once("close", retry);
incoming.destroy();
} else {
retry();
}
return true;
};
function requestUpstream() {
if (res.destroyed) return;
upstream = transport.request(
{
hostname: target.hostname,
port: target.port,
path: target.path,
method: req.method,
headers,
...(target.protocol === "https:" ? { servername: target.hostname } : {}),
},
(incoming) => {
if ((incoming.statusCode ?? 502) >= 500 && scheduleRetry(incoming)) {
return;
}
res.writeHead(
incoming.statusCode ?? 502,
safeScreenProxyResponseHeaders(incoming.headers),
);
incoming.pipe(res);
},
);
upstream.on("error", () => {
if (retryable && !res.headersSent && !res.destroyed && (retryPending || scheduleRetry())) {
return;
}
stopChecking();
if (res.headersSent) {
res.destroy();
return;
}
res.statusCode = 502;
res.end("Screen unavailable");
});
if (req.method === "GET" || req.readableEnded) upstream.end();
else req.pipe(upstream);
}
stopChecking = watchScreenAuthorization(
async () => Boolean(await resolveNovncTarget(req.url, secret, api)),
() => {
upstream?.destroy();
res.destroy();
},
);
res.once("close", () => {
stopChecking();
upstream?.destroy();
});
requestUpstream();
});
server.httpServer?.on("upgrade", async (req, socket, head) => {
if (!req.url?.startsWith("/novnc/")) return;
const target = await resolveNovncTarget(req.url, secret, api);
if (socket.destroyed) return;
if (!target) {
socket.destroy();
return;
}
const upstream =
target.protocol === "https:"
? tls.connect({ port: target.port, host: target.hostname, servername: target.hostname })
: net.connect(target.port, target.hostname);
const stopChecking = watchScreenAuthorization(
async () => Boolean(await resolveNovncTarget(req.url, secret, api)),
() => {
socket.destroy();
upstream.destroy();
},
);
socket.once("close", () => {
stopChecking();
upstream.destroy();
});
upstream.once("close", () => {
stopChecking();
socket.destroy();
});
upstream.once(target.protocol === "https:" ? "secureConnect" : "connect", () => {
const headerLines = [
`${req.method ?? "GET"} ${target.path} HTTP/1.1`,
`Host: ${target.hostname}:${target.port}`,
];
for (const [key, value] of Object.entries(safeProxyHeaders(req.headers))) {
headerLines.push(`${key}: ${Array.isArray(value) ? value.join(",") : value}`);
}
upstream.write(`${headerLines.join("\r\n")}\r\n\r\n`);
if (head.length) upstream.write(head);
socket.pipe(upstream);
const responseChunks: Buffer[] = [];
let responseSize = 0;
let responseTail = Buffer.alloc(0);
const forwardHandshake = (chunk: Buffer) => {
responseChunks.push(chunk);
responseSize += chunk.length;
if (responseSize > 64 * 1024) {
socket.destroy();
upstream.destroy();
return;
}
const boundarySearch = Buffer.concat([responseTail, chunk]);
if (boundarySearch.indexOf("\r\n\r\n") < 0) {
responseTail = Buffer.from(boundarySearch.subarray(-3));
return;
}
const responseHead = Buffer.concat(responseChunks, responseSize);
const safe = stripSensitiveHandshakeHeaders(responseHead);
if (!safe) {
socket.destroy();
upstream.destroy();
return;
}
upstream.off("data", forwardHandshake);
socket.write(safe);
upstream.pipe(socket);
};
upstream.on("data", forwardHandshake);
});
upstream.on("error", () => socket.destroy());
socket.on("error", () => upstream.destroy());
});
}
export default defineConfig(({ mode }) => {
const rootEnv = loadEnv(mode, path.resolve(import.meta.dirname, "../.."), "");
const api = process.env.API_PROXY_TARGET ?? rootEnv.API_PROXY_TARGET ?? "http://127.0.0.1:3100";
const previewHost = process.env.RAKAZO_HOST ?? rootEnv.RAKAZO_HOST ?? "localhost";
const screenProxySecret = () =>
resolveScreenProxySecret({
...process.env,
SCREEN_PROXY_SECRET: process.env.SCREEN_PROXY_SECRET ?? rootEnv.SCREEN_PROXY_SECRET,
SANDBOX_SUPERVISOR_TOKEN:
process.env.SANDBOX_SUPERVISOR_TOKEN ?? rootEnv.SANDBOX_SUPERVISOR_TOKEN,
BETTER_AUTH_SECRET: process.env.BETTER_AUTH_SECRET ?? rootEnv.BETTER_AUTH_SECRET,
});
const performanceAssetDelayMs = Number(process.env.RAKAZO_PERFORMANCE_ASSET_DELAY_MS ?? 0);
const desktopStackToken =
process.env.RAKAZO_DESKTOP_STACK_TOKEN ?? rootEnv.RAKAZO_DESKTOP_STACK_TOKEN ?? "";
const imageTag = process.env.RAKAZO_IMAGE_TAG ?? rootEnv.RAKAZO_IMAGE_TAG ?? "edge";
return {
plugins: [
react(),
babel({ plugins: ["@lingui/babel-plugin-lingui-macro"] }),
lingui(),
tailwindcss(),
{
name: "rakazo-desktop-stack-probe",
configureServer: (server) => attachDesktopStackProbe(server, desktopStackToken, imageTag),
configurePreviewServer: (server) =>
attachDesktopStackProbe(server, desktopStackToken, imageTag),
},
{
name: "rakazo-performance-asset-delay",
configurePreviewServer(server) {
if (!Number.isFinite(performanceAssetDelayMs) || performanceAssetDelayMs <= 0) return;
server.middlewares.use((req, _res, next) => {
const pathname = req.url?.split("?", 1)[0] ?? "/";
if (["/api", "/rpc", "/novnc"].some((prefix) => pathname.startsWith(prefix))) {
next();
return;
}
setTimeout(next, performanceAssetDelayMs);
});
},
},
{
name: "rakazo-novnc-proxy",
configureServer: (server) => attachNovncProxy(server, screenProxySecret(), api),
configurePreviewServer: (server) => attachNovncProxy(server, screenProxySecret(), api),
},
],
server: {
host: "0.0.0.0",
port: webPort,
strictPort: true,
allowedHosts: true,
proxy: {
"/api": { target: api, changeOrigin: true },
"/rpc": { target: api, changeOrigin: true },
},
},
preview: {
host: "0.0.0.0",
port: Number(process.env.WEB_PORT ?? 5173),
allowedHosts: [previewHost],
proxy: {
"/api": { target: api, changeOrigin: true },
"/rpc": { target: api, changeOrigin: true },
},
},
};
});

View File

@ -0,0 +1,296 @@
import { timingSafeEqual } from "node:crypto";
import type { ClientRequest, IncomingMessage } from "node:http";
import http from "node:http";
import https from "node:https";
import net from "node:net";
import path from "node:path";
import tls from "node:tls";
import { lingui } from "@lingui/vite-plugin";
import type { DesktopStackProbeResponse } from "@rakazo/contracts";
import {
safeScreenProxyResponseHeaders,
stripSensitiveHandshakeHeaders,
} from "@rakazo/core/node/screen-proxy-response";
import babel from "@rolldown/plugin-babel";
import tailwindcss from "@tailwindcss/vite";
import react from "@vitejs/plugin-react";
import type { PreviewServer, ViteDevServer } from "vite";
import { defineConfig, loadEnv } from "vite";
import { resolveScreenProxySecret } from "../../packages/core/src/secrets-guard.ts";
import {
resolveNovncTarget,
safeProxyHeaders,
watchScreenAuthorization,
} from "./src/screen-proxy.js";
const webPort = Number(process.env.WEB_PORT ?? 5173);
const DESKTOP_STACK_PROBE_PATH = "/.well-known/rakazo-desktop-stack";
const DESKTOP_STACK_TOKEN_HEADER = "x-rakazo-desktop-stack-token";
function equalStackToken(expected: string, supplied: string | string[] | undefined) {
if (expected === "" || typeof supplied !== "string") return false;
const expectedBytes = Buffer.from(expected);
const suppliedBytes = Buffer.from(supplied);
return (
expectedBytes.byteLength === suppliedBytes.byteLength &&
timingSafeEqual(expectedBytes, suppliedBytes)
);
}
function attachDesktopStackProbe(
server: ViteDevServer | PreviewServer,
token: string,
imageTag: string,
) {
server.middlewares.use((req, res, next) => {
if (req.url?.split("?", 1)[0] !== DESKTOP_STACK_PROBE_PATH) {
next();
return;
}
if (!equalStackToken(token, req.headers[DESKTOP_STACK_TOKEN_HEADER])) {
res.statusCode = 404;
res.end("Not found");
return;
}
const body: DesktopStackProbeResponse = { ok: true, imageTag };
res.statusCode = 200;
res.setHeader("cache-control", "no-store");
res.setHeader("content-type", "application/json; charset=utf-8");
res.end(JSON.stringify(body));
});
}
function attachNovncProxy(server: ViteDevServer | PreviewServer, secret: string, api: string) {
server.middlewares.use(async (req, res, next) => {
if (!req.url?.startsWith("/novnc/")) {
next();
return;
}
const target = await resolveNovncTarget(req.url, secret, api);
if (res.destroyed) return;
if (!target) {
res.statusCode = 403;
res.end("Invalid or expired screen capability");
return;
}
const headers = {
...safeProxyHeaders(req.headers),
host: `${target.hostname}:${target.port}`,
};
const transport = target.protocol === "https:" ? https : http;
let upstream: ClientRequest | undefined;
let retries = 0;
let retryPending = false;
let stopChecking: () => void = () => undefined;
const retryable = req.method === "GET";
const scheduleRetry = (incoming?: IncomingMessage) => {
if (!retryable || retryPending || retries >= 3 || res.destroyed) return false;
retryPending = true;
retries += 1;
const delayMs = 50 * retries;
const retry = () => {
setTimeout(() => {
retryPending = false;
if (!res.destroyed) requestUpstream();
}, delayMs);
};
if (incoming && !incoming.destroyed) {
incoming.once("close", retry);
incoming.destroy();
} else {
retry();
}
return true;
};
function requestUpstream() {
if (res.destroyed) return;
upstream = transport.request(
{
hostname: target.hostname,
port: target.port,
path: target.path,
method: req.method,
headers,
...(target.protocol === "https:" ? { servername: target.hostname } : {}),
},
(incoming) => {
if ((incoming.statusCode ?? 502) >= 500 && scheduleRetry(incoming)) {
return;
}
res.writeHead(
incoming.statusCode ?? 502,
safeScreenProxyResponseHeaders(incoming.headers),
);
incoming.pipe(res);
},
);
upstream.on("error", () => {
if (retryable && !res.headersSent && !res.destroyed && (retryPending || scheduleRetry())) {
return;
}
stopChecking();
if (res.headersSent) {
res.destroy();
return;
}
res.statusCode = 502;
res.end("Screen unavailable");
});
if (req.method === "GET" || req.readableEnded) upstream.end();
else req.pipe(upstream);
}
stopChecking = watchScreenAuthorization(
async () => Boolean(await resolveNovncTarget(req.url, secret, api)),
() => {
upstream?.destroy();
res.destroy();
},
);
res.once("close", () => {
stopChecking();
upstream?.destroy();
});
requestUpstream();
});
server.httpServer?.on("upgrade", async (req, socket, head) => {
if (!req.url?.startsWith("/novnc/")) return;
const target = await resolveNovncTarget(req.url, secret, api);
if (socket.destroyed) return;
if (!target) {
socket.destroy();
return;
}
const upstream =
target.protocol === "https:"
? tls.connect({ port: target.port, host: target.hostname, servername: target.hostname })
: net.connect(target.port, target.hostname);
const stopChecking = watchScreenAuthorization(
async () => Boolean(await resolveNovncTarget(req.url, secret, api)),
() => {
socket.destroy();
upstream.destroy();
},
);
socket.once("close", () => {
stopChecking();
upstream.destroy();
});
upstream.once("close", () => {
stopChecking();
socket.destroy();
});
upstream.once(target.protocol === "https:" ? "secureConnect" : "connect", () => {
const headerLines = [
`${req.method ?? "GET"} ${target.path} HTTP/1.1`,
`Host: ${target.hostname}:${target.port}`,
];
for (const [key, value] of Object.entries(safeProxyHeaders(req.headers))) {
headerLines.push(`${key}: ${Array.isArray(value) ? value.join(",") : value}`);
}
upstream.write(`${headerLines.join("\r\n")}\r\n\r\n`);
if (head.length) upstream.write(head);
socket.pipe(upstream);
const responseChunks: Buffer[] = [];
let responseSize = 0;
let responseTail = Buffer.alloc(0);
const forwardHandshake = (chunk: Buffer) => {
responseChunks.push(chunk);
responseSize += chunk.length;
if (responseSize > 64 * 1024) {
socket.destroy();
upstream.destroy();
return;
}
const boundarySearch = Buffer.concat([responseTail, chunk]);
if (boundarySearch.indexOf("\r\n\r\n") < 0) {
responseTail = Buffer.from(boundarySearch.subarray(-3));
return;
}
const responseHead = Buffer.concat(responseChunks, responseSize);
const safe = stripSensitiveHandshakeHeaders(responseHead);
if (!safe) {
socket.destroy();
upstream.destroy();
return;
}
upstream.off("data", forwardHandshake);
socket.write(safe);
upstream.pipe(socket);
};
upstream.on("data", forwardHandshake);
});
upstream.on("error", () => socket.destroy());
socket.on("error", () => upstream.destroy());
});
}
export default defineConfig(({ mode }) => {
const rootEnv = loadEnv(mode, path.resolve(import.meta.dirname, "../.."), "");
const api = process.env.API_PROXY_TARGET ?? rootEnv.API_PROXY_TARGET ?? "http://127.0.0.1:3100";
const previewHost = process.env.RAKAZO_HOST ?? rootEnv.RAKAZO_HOST ?? "localhost";
const screenProxySecret = () =>
resolveScreenProxySecret({
...process.env,
SCREEN_PROXY_SECRET: process.env.SCREEN_PROXY_SECRET ?? rootEnv.SCREEN_PROXY_SECRET,
SANDBOX_SUPERVISOR_TOKEN:
process.env.SANDBOX_SUPERVISOR_TOKEN ?? rootEnv.SANDBOX_SUPERVISOR_TOKEN,
BETTER_AUTH_SECRET: process.env.BETTER_AUTH_SECRET ?? rootEnv.BETTER_AUTH_SECRET,
});
const performanceAssetDelayMs = Number(process.env.RAKAZO_PERFORMANCE_ASSET_DELAY_MS ?? 0);
const desktopStackToken =
process.env.RAKAZO_DESKTOP_STACK_TOKEN ?? rootEnv.RAKAZO_DESKTOP_STACK_TOKEN ?? "";
const imageTag = process.env.RAKAZO_IMAGE_TAG ?? rootEnv.RAKAZO_IMAGE_TAG ?? "edge";
return {
plugins: [
react(),
babel({ plugins: ["@lingui/babel-plugin-lingui-macro"] }),
lingui(),
tailwindcss(),
{
name: "rakazo-desktop-stack-probe",
configureServer: (server) => attachDesktopStackProbe(server, desktopStackToken, imageTag),
configurePreviewServer: (server) =>
attachDesktopStackProbe(server, desktopStackToken, imageTag),
},
{
name: "rakazo-performance-asset-delay",
configurePreviewServer(server) {
if (!Number.isFinite(performanceAssetDelayMs) || performanceAssetDelayMs <= 0) return;
server.middlewares.use((req, _res, next) => {
const pathname = req.url?.split("?", 1)[0] ?? "/";
if (["/api", "/rpc", "/novnc"].some((prefix) => pathname.startsWith(prefix))) {
next();
return;
}
setTimeout(next, performanceAssetDelayMs);
});
},
},
{
name: "rakazo-novnc-proxy",
configureServer: (server) => attachNovncProxy(server, screenProxySecret(), api),
configurePreviewServer: (server) => attachNovncProxy(server, screenProxySecret(), api),
},
],
server: {
host: "0.0.0.0",
port: webPort,
strictPort: true,
allowedHosts: true,
proxy: {
"/api": { target: api, changeOrigin: true },
"/rpc": { target: api, changeOrigin: true },
},
},
preview: {
host: "0.0.0.0",
port: Number(process.env.WEB_PORT ?? 5173),
allowedHosts: [previewHost],
proxy: {
"/api": { target: api, changeOrigin: true },
"/rpc": { target: api, changeOrigin: true },
},
},
};
});

View File

@ -0,0 +1,5 @@
# evOo-local — Agenten-Interface only
Primary: multi-agent / Oo UI at `/app/`.
Not primary: Mission Control, Cockpit, trading dashboards, old HTML evoo-platform.
Spelling: **evOo** (letter O).

2500
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,43 @@
import type { MessageBlock } from "@rakazo/contracts";
export const TURN_ATTACHMENT_UNAVAILABLE =
"An attachment in this message could not be loaded. Tell the user the attachment was unavailable and do not guess its contents.";
export const STEERING_ATTACHMENT_UNAVAILABLE = TURN_ATTACHMENT_UNAVAILABLE;
export function missingTurnImagesInstruction(
blocks: MessageBlock[] | undefined,
images: { length: number } | undefined,
): string {
const expected = blocks?.filter((block) => block.kind === "image").length ?? 0;
const loaded = images?.length ?? 0;
return expected > 0 && loaded < expected ? TURN_ATTACHMENT_UNAVAILABLE : "";
}
export async function settleSteeringAttachmentLoads<TImage, TFile>(
images: Promise<TImage[] | undefined>,
files: Promise<TFile[]>,
blocks?: MessageBlock[],
signal?: AbortSignal,
): Promise<{
images: TImage[] | undefined;
files: TFile[];
unavailableInstruction: string;
}> {
const [loadedImages, loadedFiles] = await Promise.allSettled([images, files]);
if (signal?.aborted) {
if (loadedImages.status === "rejected") throw loadedImages.reason;
if (loadedFiles.status === "rejected") throw loadedFiles.reason;
}
const expectedImageCount = blocks?.filter((block) => block.kind === "image").length ?? 0;
const loadedImageCount =
loadedImages.status === "fulfilled" ? (loadedImages.value?.length ?? 0) : 0;
const unavailable =
loadedImages.status === "rejected" ||
loadedFiles.status === "rejected" ||
loadedImageCount < expectedImageCount;
return {
images: loadedImages.status === "fulfilled" ? loadedImages.value : undefined,
files: loadedFiles.status === "fulfilled" ? loadedFiles.value : [],
unavailableInstruction: unavailable ? STEERING_ATTACHMENT_UNAVAILABLE : "",
};
}

View File

@ -0,0 +1,23 @@
/** Avoid an expensive remote workspace export when a turn never touched the computer. */
export function createRunWorkspaceCheckpoint(checkpoint: () => Promise<unknown>) {
let dirty = false;
return {
markDirty() {
dirty = true;
},
markFiles(files: readonly unknown[]) {
if (files.length > 0) dirty = true;
},
async flush() {
if (!dirty) return false;
dirty = false;
try {
await checkpoint();
return true;
} catch (error) {
dirty = true;
throw error;
}
},
};
}

View File

@ -0,0 +1,65 @@
import type { MessageBlock } from "@rakazo/contracts";
/** Build completion message blocks for the final bot reply. */
export function completionMessageSegments(
segments: MessageBlock[],
options?: {
allowSilentEmpty?: boolean;
emptyResponseText?: string;
suppressOutput?: boolean;
skipEmptyFallback?: boolean;
},
): MessageBlock[] {
if (options?.suppressOutput) return [];
const fallback = options?.emptyResponseText?.trim() || "done.";
if (segments.length > 0) {
if (
!options?.allowSilentEmpty &&
options?.emptyResponseText !== undefined &&
!segments.some((segment) => segment.kind === "text" && segment.text)
) {
return [...segments, { kind: "text", text: fallback }];
}
return segments;
}
if (options?.allowSilentEmpty || options?.skipEmptyFallback) return [];
return [{ kind: "text", text: fallback }];
}
/** User-facing text for completion notifications; empty when only tool/step activity remains. */
export function completionNotificationBody(assembled: string, blocks: MessageBlock[]): string {
if (assembled) return assembled;
return blocks
.filter((block): block is Extract<MessageBlock, { kind: "text" }> => block.kind === "text")
.map((block) => block.text)
.join("");
}
export function completionMarksUnread(trigger: string, text: string): boolean {
return trigger !== "routine" || Boolean(text);
}
export function threadContextForRun<T>(
trigger: string,
context: {
messages: T[];
summary: string | null;
historyCompactedUpToSeq: number | null;
},
messagingChannelRun: boolean,
) {
return trigger === "routine"
? {
messages: [] as T[],
summary: null,
historyCompactedUpToSeq: null,
includeSemanticRecall: false,
}
: messagingChannelRun
? { ...context, summary: null, historyCompactedUpToSeq: null, includeSemanticRecall: false }
: { ...context, includeSemanticRecall: true };
}
export function subagentMarksUnread(trigger: string, status: "running" | "completed" | "failed") {
return status === "failed" || trigger !== "routine";
}

View File

@ -0,0 +1,231 @@
import type {
NotificationMessage,
NotificationProvider,
} from "@rakazo/adapter-kit";
import { runContinueJob } from "@rakazo/adapter-kit";
import type { MessageBlock } from "@rakazo/contracts";
import type { PrismaClient, Prisma } from "@rakazo/db";
import {
appendEventInTransaction,
createThreadMessageInTransaction,
} from "@rakazo/db";
import { redactSecrets } from "@rakazo/core";
import { getLogger } from "@rakazo/logging";
import type { ExecutorDeps } from "./executor.js";
import { withComputerScreenAvailability } from "./computer-screens.js";
import {
TAKEOVER_RESUME_CHECKPOINTS,
type TakeoverResumeCheckpoint,
} from "./takeover-resume.js";
import { sanitizeTextBlocks } from "./prose-handoff-recovery.js";
// Helper used by publishMessage — also exported so executor.ts can call it directly
export async function persistMessageInTransaction(
tx: Prisma.TransactionClient,
run: { id: string; spaceId: string; threadId: string; botId: string },
role: "user" | "bot" | "system",
blocks: MessageBlock[],
markUnread?: boolean,
clientNonce?: string,
) {
const message = await createThreadMessageInTransaction(tx, {
threadId: run.threadId,
role,
blocks,
botId: run.botId,
runId: run.id,
markUnread,
clientNonce,
});
const event = await appendEventInTransaction(tx, {
spaceId: run.spaceId,
threadId: run.threadId,
botId: run.botId,
type: "thread.message.created",
runId: run.id,
payload: { messageId: message.id, role, blocks },
});
return { message, eventSeq: event.seq };
}
export async function computerScreenToolResult(
work: () => Promise<unknown>,
finish?: (result: unknown) => Promise<unknown>,
) {
const result = await withComputerScreenAvailability(work);
return finish ? finish(result) : result;
}
export async function runNotificationsEnabled(
prisma: PrismaClient,
run: { spaceId: string; userId: string; botId: string; threadId: string },
): Promise<boolean> {
const source = await prisma.run.findFirst({
where: {
botId: run.botId,
threadId: run.threadId,
spaceId: run.spaceId,
userId: run.userId,
},
select: {
bot: { select: { notifyOnFinish: true } },
thread: { select: { groupId: true } },
},
});
return Boolean(source && (source.thread.groupId || source.bot.notifyOnFinish));
}
export async function notifyRun(
deps: ExecutorDeps,
run: { spaceId: string; userId: string; botId: string; threadId: string },
message: NotificationMessage,
) {
if (!deps.notifications) return;
const enabled = await runNotificationsEnabled(deps.prisma, run).catch((error) => {
getLogger().error("notification preference lookup", error);
return false;
});
if (!enabled) return;
await deps.notifications
.send(message, {
operationId: "notify",
traceId: run.botId,
spaceId: run.spaceId,
userId: run.userId,
botId: run.botId,
signal: new AbortController().signal,
})
.catch((error) => {
getLogger().error("run notification", error);
});
}
export async function renewRunLease(
deps: ExecutorDeps,
runId: string,
workerId: string,
fence: number,
): Promise<boolean> {
const renewed = await deps.prisma.run.updateMany({
where: { id: runId, status: "running", leaseOwner: workerId, leaseFence: fence },
data: { leaseExpiresAt: new Date(Date.now() + 5 * 60_000) },
});
return renewed.count === 1;
}
export function computerRetryDelay(fence: number): number {
return Math.min(10_000, 250 * 2 ** Math.min(Math.max(fence - 1, 0), 5));
}
export function computerRunRequeueData(
resumeCheckpoint: TakeoverResumeCheckpoint | null,
error: string | null = null,
heldForTakeover = false,
) {
return {
status:
heldForTakeover && !resumeCheckpoint ? ("waiting_takeover" as const) : ("queued" as const),
error,
leaseOwner: null,
leaseExpiresAt: null,
checkpoint: resumeCheckpoint,
};
}
export async function writeComputerRunRequeue(
deps: ExecutorDeps,
runId: string,
workerId: string,
fence: number,
resumeCheckpoint: TakeoverResumeCheckpoint | null,
heldForTakeover = false,
error: string | null = null,
): Promise<boolean> {
const whereLease = {
id: runId,
status: "running" as const,
leaseOwner: workerId,
leaseFence: fence,
};
const releasedHold = {
status: "queued" as const,
error,
leaseOwner: null,
leaseExpiresAt: null,
};
const preserve = await deps.prisma.run.updateMany({
where: {
...whereLease,
checkpoint: { in: [...TAKEOVER_RESUME_CHECKPOINTS] },
},
data: releasedHold,
});
if (preserve.count === 1) return true;
const planned = await deps.prisma.run.updateMany({
where: { ...whereLease, checkpoint: null },
data: computerRunRequeueData(resumeCheckpoint, error, heldForTakeover),
});
if (planned.count === 1) return true;
const retried = await deps.prisma.run.updateMany({
where: {
...whereLease,
checkpoint: { in: [...TAKEOVER_RESUME_CHECKPOINTS] },
},
data: releasedHold,
});
return retried.count === 1;
}
export async function requeueComputerRun(
deps: ExecutorDeps,
runId: string,
workerId: string,
fence: number,
resumeCheckpoint: TakeoverResumeCheckpoint | null,
heldForTakeover = false,
): Promise<void> {
const released = await writeComputerRunRequeue(
deps,
runId,
workerId,
fence,
resumeCheckpoint,
heldForTakeover,
);
if (!released) return;
await deps.jobs.enqueue({
...runContinueJob(runId),
availableAt: new Date(Date.now() + computerRetryDelay(fence)),
});
}
export function redactBlocks(blocks: MessageBlock[], secrets: string[]): MessageBlock[] {
return sanitizeTextBlocks(
blocks.map((block) => {
if (block.kind === "text") {
return { kind: "text" as const, text: redactSecrets(block.text, secrets) };
}
if (block.kind === "bot_message_sent" || block.kind === "bot_message_received") {
return { ...block, text: redactSecrets(block.text, secrets) };
}
return block;
}),
);
}
export async function publishMessage(
deps: ExecutorDeps,
run: { id: string; spaceId: string; threadId: string; botId: string },
role: "user" | "bot" | "system",
blocks: MessageBlock[],
markUnread?: boolean,
clientNonce?: string,
) {
const committed = await deps.prisma.$transaction((tx) =>
persistMessageInTransaction(tx, run, role, blocks, markUnread, clientNonce),
);
await deps.events.notify(run.threadId, committed.eventSeq).catch((error) => {
getLogger().error("thread message realtime notification", error);
});
return committed.message;
}

View File

@ -0,0 +1,151 @@
import type { PrismaClient, ThreadEvents } from "@rakazo/db";
import { completeExternalEffect } from "./approval-effect.js";
import {
isToolEffectIdempotencyKey,
legacyScopedToolEffectIdempotencyKey,
stableJsonValue,
} from "@rakazo/core/node/approval-effect-key";
/** Minimal dependencies for effect recording/completion. */
type EffectDeps = {
prisma: PrismaClient;
events: ThreadEvents;
};
export async function recordEffect(
deps: EffectDeps,
run: { id: string; spaceId: string; threadId: string; botId: string },
kind: string,
idempotencyKey: string,
request: unknown,
legacyIdempotencyKey?: string,
consumedIds?: Set<string>,
): Promise<{ duplicate: boolean; effect: { id: string; [key: string]: unknown } }> {
const existing = await deps.prisma.externalEffect.findUnique({
where: { idempotencyKey },
});
if (existing) {
consumedIds?.add(existing.id);
await deps.events.append({
spaceId: run.spaceId,
threadId: run.threadId,
botId: run.botId,
type: "effect.reconciled",
runId: run.id,
payload: { executionId: idempotencyKey, kind },
});
return { duplicate: true, effect: existing as unknown as { id: string; [key: string]: unknown } };
}
// Pre-fix rows used bare provider ids or scoped keys that included the
// ephemeral model tool-call id. Same-id unique lookup still works; a restart
// with a new id finds the row by run, tool, and request instead.
let expectedRequest: string | undefined;
try {
expectedRequest = stableJsonValue(request);
} catch {
expectedRequest = undefined;
}
if (legacyIdempotencyKey && legacyIdempotencyKey !== idempotencyKey && expectedRequest) {
const scopedLegacy =
request && typeof request === "object" && !Array.isArray(request)
? legacyScopedToolEffectIdempotencyKey(
run.id,
kind,
legacyIdempotencyKey,
request as Record<string, unknown>,
)
: undefined;
for (const candidate of [scopedLegacy, legacyIdempotencyKey]) {
if (!candidate || candidate === idempotencyKey) continue;
const sameIdLegacy = await deps.prisma.externalEffect.findUnique({
where: { idempotencyKey: candidate },
});
if (
sameIdLegacy &&
!consumedIds?.has(sameIdLegacy.id) &&
sameIdLegacy.runId === run.id &&
sameIdLegacy.kind === kind &&
stableJsonValue(sameIdLegacy.request) === expectedRequest
) {
consumedIds?.add(sameIdLegacy.id);
await deps.events.append({
spaceId: run.spaceId,
threadId: run.threadId,
botId: run.botId,
type: "effect.reconciled",
runId: run.id,
payload: { executionId: candidate, kind, legacy: true },
});
return { duplicate: true, effect: sameIdLegacy as unknown as { id: string; [key: string]: unknown } };
}
}
}
const prior = await deps.prisma.externalEffect.findMany({
where: { runId: run.id, kind },
orderBy: { createdAt: "asc" },
});
const legacy =
expectedRequest === undefined
? undefined
: prior.find((candidate) => {
if (consumedIds?.has(candidate.id)) return false;
if (candidate.idempotencyKey === idempotencyKey) return false;
if (candidate.runId !== run.id || candidate.kind !== kind) return false;
try {
if (stableJsonValue(candidate.request) !== expectedRequest) return false;
} catch {
return false;
}
// Live later occurrences use a new modern key; do not steal an earlier modern row.
return !isToolEffectIdempotencyKey(candidate.idempotencyKey, run.id, kind);
});
if (legacy) {
consumedIds?.add(legacy.id);
await deps.events.append({
spaceId: run.spaceId,
threadId: run.threadId,
botId: run.botId,
type: "effect.reconciled",
runId: run.id,
payload: { executionId: legacy.idempotencyKey, kind, legacy: true },
});
return { duplicate: true, effect: legacy as unknown as { id: string; [key: string]: unknown } };
}
const effect = await deps.prisma.externalEffect.create({
data: {
spaceId: run.spaceId,
runId: run.id,
kind,
idempotencyKey,
status: "intended",
request: request as never,
},
});
consumedIds?.add(effect.id);
return { duplicate: false, effect };
}
export async function completeEffect(
deps: EffectDeps,
effectId: string,
expectedStatus: "intended" | "executing",
result: unknown,
) {
const storedResult =
result &&
typeof result === "object" &&
(result as { kind?: unknown }).kind === "agent_tool_result" &&
"details" in result
? (result as { details: unknown }).details
: result;
return completeExternalEffect(deps.prisma, effectId, expectedStatus, storedResult as never);
}
export function uncertainEffectError(toolName: string): Error {
return new Error(
`tool ${toolName} has an earlier execution with an uncertain outcome; it may already have completed, so verify the destination before retrying`,
);
}

View File

@ -0,0 +1,207 @@
import type { AgentModelOAuthCredential } from "@rakazo/adapter-kit";
import type { AgentRunRequest } from "@rakazo/adapter-kit";
import { OPENAI_COMPATIBLE_PROVIDER_ID } from "@rakazo/contracts";
import { resolveDeploymentModel } from "./deployment-model.js";
import { modelIdSupportsImages } from "./model-vision.js";
import { toOAuthCredential } from "./pi-credentials.js";
import {
parseModelSecret,
resolveModelAuth,
secretValuesToRedact,
serializeModelSecret,
} from "./pi-oauth.js";
import type { ExecutorDeps } from "./executor-types.js";
const modelCredentialLocks = new Map<string, Promise<void>>();
/**
* Return a deployment API key when the winning provider matches the
* configured deployment model provider. A provider named by deployment
* settings or a bot override gets no key rather than another vendor's.
*/
export function deploymentKeyFor(
deps: ExecutorDeps,
provider: string,
): string | undefined {
if (!deps.deploymentModelKey) return undefined;
return provider === resolveDeploymentModel().provider
? deps.deploymentModelKey
: undefined;
}
/**
* Resolve model credentials for a given provider/model pair.
*
* When a credential row exists, fetch the secret from the database,
* apply any required OAuth flow, and return the resolved API key plus
* metadata (base URL, reasoning flags, image support, etc.).
*
* When no credential exists, fall back to the deployment key for the
* winning provider.
*/
export async function resolveModelKey(
deps: ExecutorDeps,
userId: string,
spaceId: string,
credential: {
secretId: string;
provider: string;
defaultModel?: string | null;
supportsImages?: boolean;
} | null,
provider: string,
modelId: string,
registerSecrets?: (values: string[]) => void,
): Promise<{
apiKey?: string;
baseUrl?: string;
reasoning?: boolean;
maxTokens?: number;
contextWindow?: number;
thinkingLevel?: AgentRunRequest["model"]["thinkingLevel"];
acceptsImages?: boolean;
maxImagesPerPrompt?: number;
oauth?: AgentModelOAuthCredential;
persistOAuth?: (credential: AgentModelOAuthCredential) => Promise<void>;
redact: string[];
}> {
if (credential) {
return withModelCredentialLock(credential.secretId, async () => {
const row = await deps.prisma.secret.findFirst({
where: { id: credential.secretId, userId, spaceId: null },
});
if (!row)
return { apiKey: deploymentKeyFor(deps, provider), redact: [] };
const plaintext = deps.secretStore.load(row.ciphertext, row.id);
registerSecrets?.(secretValuesToRedact(parseModelSecret(plaintext)));
const persist = async (next: string) => {
const stored = await deps.secretStore.put(
next,
{
operationId: "cred",
traceId: "cred-refresh",
spaceId,
userId,
signal: new AbortController().signal,
},
row.id,
);
await deps.prisma.secret.update({
where: { id: row.id },
data: { ciphertext: stored.ciphertext },
});
};
const resolved = await resolveModelAuth(plaintext, credential.provider, {
persist,
});
const oauth =
resolved.secret.kind === "oauth"
? resolved.secret.credential
: undefined;
const baseUrl =
resolved.secret.kind === "openai_compatible"
? resolved.secret.baseUrl
: undefined;
const acceptsImages =
credential.provider === OPENAI_COMPATIBLE_PROVIDER_ID &&
resolved.secret.kind === "openai_compatible" &&
(modelIdSupportsImages(resolved.secret.visionModelIds, modelId) ||
// Legacy secrets have no per-model list, so keep their existing
// capability scoped to the model saved in the space preference.
(resolved.secret.visionModelIds === undefined &&
credential.supportsImages === true &&
credential.defaultModel?.trim() === modelId.trim()));
return {
apiKey: resolved.apiKey,
baseUrl,
reasoning:
resolved.secret.kind === "openai_compatible"
? resolved.secret.reasoning
: undefined,
maxTokens:
resolved.secret.kind === "openai_compatible"
? resolved.secret.maxTokens
: undefined,
contextWindow:
resolved.secret.kind === "openai_compatible"
? resolved.secret.contextWindow
: undefined,
thinkingLevel:
resolved.secret.kind === "openai_compatible"
? resolved.secret.thinkingLevel
: undefined,
acceptsImages,
maxImagesPerPrompt:
resolved.secret.kind === "openai_compatible"
? resolved.secret.maxImagesPerPrompt
: undefined,
oauth,
persistOAuth: oauth
? async (next) => {
await withModelCredentialLock(credential.secretId, async () => {
const currentRow = await deps.prisma.secret.findFirst({
where: { id: credential.secretId, userId, spaceId: null },
});
if (!currentRow) return;
const current = parseModelSecret(
deps.secretStore.load(
currentRow.ciphertext,
currentRow.id,
),
);
if (current.kind === "oauth") {
const stored = current.credential;
if (stored.expires > next.expires) return;
if (
stored.access === next.access &&
stored.refresh === next.refresh &&
stored.expires === next.expires
) {
return;
}
}
await persist(
serializeModelSecret({
kind: "oauth",
credential: toOAuthCredential(next),
}),
);
});
}
: undefined,
redact: [...secretValuesToRedact(resolved.secret), resolved.apiKey].filter(
(value): value is string => Boolean(value),
),
};
});
}
return { apiKey: deploymentKeyFor(deps, provider), redact: [] };
}
/**
* Serialize access to credential operations for a single secret key.
* Prevents concurrent reads/writes from producing torn state.
*/
export async function withModelCredentialLock<T>(
key: string,
fn: () => Promise<T>,
): Promise<T> {
const previous = modelCredentialLocks.get(key) ?? Promise.resolve();
let release!: () => void;
const current = previous.then(
() =>
new Promise<void>((resolve) => {
release = resolve;
}),
);
modelCredentialLocks.set(key, current);
await previous;
try {
return await fn();
} finally {
release();
if (modelCredentialLocks.get(key) === current)
modelCredentialLocks.delete(key);
}
}

View File

@ -0,0 +1,177 @@
/**
* Shell command safety guard — detects dangerous lifecycle commands
* (kill, systemctl stop, browser profile access, etc.) by tokenizing
* the command through shell-quote.
*
* Extracted from executor.ts to reduce file size.
*/
import { parse as parseShellCommand } from "shell-quote";
const SHELL_INTERPRETER_NAMES = /^(?:bash|sh|dash|zsh|ksh|fish)$/;
const STATIC_SHELL_EXPANSIONS: Readonly<Record<string, string>> = {
HOME: "/home/rakazo",
LOGNAME: "rakazo",
PATH: "/home/rakazo/.local/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin",
PWD: "/home/rakazo",
TMPDIR: "/tmp",
USER: "rakazo",
WORKSPACE: "/home/rakazo/workspace",
XDG_CONFIG_HOME: "/home/rakazo/.config",
};
const SAFE_SHELL_CONTROL_OPS = new Set([
"&&",
"||",
";",
"|",
"&",
">",
"<",
">>",
">&",
"<&",
"&>",
]);
function shellCFlagProgram(words: string[], interpreterIndex: number): string | undefined {
for (let index = interpreterIndex + 1; index < words.length; index += 1) {
const word = words[index] ?? "";
if (word.startsWith("--command=")) return word.slice("--command=".length);
// bash -c / -lc / -ce and fish --command: the next argument is the program string.
if (word === "--command" || /^-[^-]*c/.test(word)) return words[index + 1];
}
return undefined;
}
function preserveShellCommandBoundaries(command: string): string {
let quote: "'" | '"' | undefined;
let result = "";
for (let index = 0; index < command.length; index += 1) {
const character = command[index];
const next = command[index + 1];
if (character === "\\" && quote !== "'") {
if (next === "\n") {
index += 1;
continue;
}
// Keep escaped characters intact; an escaped quote is not a boundary.
result += character;
if (next !== undefined) {
result += next;
index += 1;
}
continue;
}
if (character === quote) quote = undefined;
else if (!quote && (character === "'" || character === '"')) quote = character;
result += character === "\n" && !quote ? "\n;" : character;
}
return result;
}
function tokenizeProtectedShellCommand(command: string): string[] | "dynamic" {
try {
// shell-quote treats newlines as whitespace. Preserve command boundaries for
// the dot builtin, after folding shell line continuations. Retaining the
// newline also preserves comment handling (comments remain fail-closed).
const separated = preserveShellCommandBoundaries(command);
const parsed = parseShellCommand<{ expansion: string }>(
separated,
(name) => STATIC_SHELL_EXPANSIONS[name] ?? { expansion: name },
{ splitUnquoted: true },
);
const words: string[] = [];
let commandPosition = true;
let redirectTarget = false;
for (const [index, entry] of parsed.entries()) {
if (typeof entry === "string") {
// Backtick fragments are not fully tokenized; treat them as dynamic.
if (entry.includes("`")) return "dynamic";
const word = entry.toLowerCase();
// `find .`, `git add .`, and `git -C .` use a path, not the
// executable `. script` builtin. Keep the path out of the builtin scan.
words.push(word === "." && (!commandPosition || redirectTarget) ? "./" : word);
if (redirectTarget) {
redirectTarget = false;
continue;
}
const next = parsed[index + 1];
if (
commandPosition &&
/^\d+$/.test(word) &&
typeof next === "object" &&
"op" in next &&
/^[<>]/.test(next.op)
) {
// A leading file descriptor belongs to a redirect, not the command.
} else if (commandPosition && /^(?:then|do|else)$/.test(word)) {
commandPosition = true;
} else if (commandPosition && (word === "coproc" || word === "function")) return "dynamic";
else if (
commandPosition &&
(/^(?:command|builtin|exec|time|if|elif|while|until|!|\{)$/.test(word) ||
word.startsWith("-") ||
/^[a-z_][a-z0-9_]*=/.test(word))
) {
// Shell prefixes and assignments leave the command word pending.
} else commandPosition = false;
continue;
}
if ("expansion" in entry) {
// Unknown expansions and command substitutions are resolved by bash
// after this guard runs, so their eventual value cannot be inspected.
return "dynamic";
}
if ("op" in entry && entry.op === "glob") {
words.push(entry.pattern.toLowerCase());
continue;
}
if ("op" in entry && SAFE_SHELL_CONTROL_OPS.has(entry.op)) {
if (["&&", "||", ";", "|", "&"].includes(entry.op)) {
commandPosition = true;
redirectTarget = false;
} else redirectTarget = true;
continue;
}
return "dynamic";
}
return words;
} catch {
return "dynamic";
}
}
export function isProtectedComputerLifecycleCommand(command: string): boolean {
const words = tokenizeProtectedShellCommand(command);
if (words === "dynamic") return true;
const commandNames = words.map((word) => word.split("/").at(-1));
if (commandNames.some((word) => /^(?:kill|pkill|killall|xkill)$/.test(word ?? ""))) {
return true;
}
// eval/source/. can hide protected commands inside an expansion string that the
// outer tokenizer keeps as a single word (e.g. eval "pkill chromium").
if (words.includes(".") || commandNames.some((word) => /^(?:eval|source)$/.test(word ?? ""))) {
return true;
}
if (
commandNames.some((word) => word === "systemctl" || word === "service") &&
words.some((word) => /^(?:stop|restart|kill)$/.test(word))
) {
return true;
}
if (
words.some((word) =>
/(?:\.browser-profiles|--user-data-dir|\/tmp\/\.x11-unix|\/tmp\/\.x\d+-lock)/.test(word),
)
) {
return true;
}
for (let index = 0; index < words.length; index += 1) {
const name = words[index]?.split("/").at(-1) ?? "";
if (!SHELL_INTERPRETER_NAMES.test(name)) continue;
const program = shellCFlagProgram(words, index);
if (program && isProtectedComputerLifecycleCommand(program)) return true;
}
return false;
}

View File

@ -0,0 +1,58 @@
import { builtinAgentTools } from "./builtin-tools.js";
import { selectCloudAgentTools } from "./cloud-agent-tools-select.js";
import { selectMemoryTools } from "./memory-tools.js";
import {
filterBuiltinToolsForRun,
filterBuiltinToolsForThread,
} from "./schedule-tools.js";
import {
filterImageReturningComputerTools,
} from "./model-vision.js";
/** Browser tools that require a graphical computer (Chrome). */
export const PAGE_BROWSER_TOOL_NAMES = new Set([
"browser_navigate",
"browser_snapshot",
"browser_act",
]);
export function filterPageBrowserTools<T extends { name: string }>(
tools: T[],
pageBrowserAllowed: boolean,
): T[] {
if (pageBrowserAllowed) return tools;
return tools.filter((tool) => !PAGE_BROWSER_TOOL_NAMES.has(tool.name));
}
export function selectBuiltinToolsForRun(options: {
graphicalToolsAllowed: boolean;
/** Page browser tools need a graphical computer (Chrome), not model vision. */
pageBrowserAllowed?: boolean;
groupId: string | null;
trigger: string;
semanticMemoryEnabled: boolean;
cloudAgentEnabled?: boolean;
messagingChannelRun: boolean;
}) {
return selectCloudAgentTools(
selectMemoryTools(
filterBuiltinToolsForRun(
filterBuiltinToolsForThread(
filterPageBrowserTools(
filterImageReturningComputerTools(builtinAgentTools, options.graphicalToolsAllowed),
options.pageBrowserAllowed ?? options.graphicalToolsAllowed,
),
options.groupId,
),
options.trigger,
),
options.semanticMemoryEnabled,
),
Boolean(options.cloudAgentEnabled),
).filter(
(tool) =>
!options.messagingChannelRun ||
(!["remember", "save_memory", "recall_memory", "forget_memory"].includes(tool.name) &&
!tool.name.startsWith("scratchpad_")),
);
}

View File

@ -0,0 +1,50 @@
import type {
AgentHomeStore,
AgentRuntime,
ArtifactStore,
BrowserProvider,
ConnectorProvider,
JobPublisher,
ManagedConnectorProvider,
MemoryStore,
NotificationProvider,
SandboxProvider,
WebProvider,
} from "@rakazo/adapter-kit";
import type { MemoryProviderResolver } from "./memory-provider-factory.js";
import type { RemoteTransportDependencies } from "./remote-mcp.js";
import type { EncryptedSecretStore } from "./secrets.js";
import type { PrismaClient, ThreadEvents } from "@rakazo/db";
import type { CloudAgentConnection } from "./cloud-agent-factory.js";
/** Shared dependencies injected into createRunExecutor and its extracted helpers. */
export interface ExecutorDeps {
prisma: PrismaClient;
events: ThreadEvents;
runtime: AgentRuntime;
sandbox: SandboxProvider;
memory: MemoryStore;
memoryProviders: MemoryProviderResolver;
home: AgentHomeStore;
artifacts?: ArtifactStore;
connector?: ConnectorProvider;
connectors?: { managed(id: string): ManagedConnectorProvider | undefined };
secrets: string[];
secretStore: EncryptedSecretStore;
deploymentModelKey?: string;
dataDir?: string;
notifications?: NotificationProvider;
jobs: JobPublisher;
/** Messaging surface; absent means zero identity queries and no chat prompts. */
messaging?: { hasIdentity(botId: string): Promise<boolean> };
listConnectedPluginSlugs?: (userId: string) => Promise<string[]>;
/** Builtin web_search / web_fetch. Defaults to keyless HTTP when omitted. */
web?: WebProvider;
/** Page browser (DOM refs) on the bot computer. Defaults to the sandbox live browser when supported. */
browser?: BrowserProvider;
secretHttp?: RemoteTransportDependencies;
/** Remote cloud coding agents. Null/omit means tools stay uninjected. */
cloudAgent?: CloudAgentConnection | null;
/** Aborted when createApp stop() begins so in-flight continueRun boot waits exit promptly. */
shutdownSignal?: AbortSignal;
}

View File

@ -0,0 +1,63 @@
import type { AgentRunRequest, MessageBlock } from "@rakazo/contracts";
import { isAttachmentImageMimeType } from "@rakazo/contracts";
import type { ExecutorDeps } from "./executor-types.js";
/** Filter connection rows to active/connected providers. */
export function selectRunConnections<
T extends { connectorId: string; provider: string; status: string },
>(rows: T[], connectedComposioProviders: string[]): T[] {
const activeKeys = new Set(connectedComposioProviders.map((provider) => `composio:${provider}`));
return rows.filter(
(row) =>
row.status !== "revoked" &&
(row.status === "connected" || activeKeys.has(`${row.connectorId}:${row.provider}`)),
);
}
/** Load image artifacts for the current turn. */
export async function loadCurrentTurnImages(
deps: ExecutorDeps,
blocks: MessageBlock[] | undefined,
context: {
operationId: string;
traceId: string;
spaceId: string;
userId: string;
botId: string;
runId: string;
signal: AbortSignal;
},
) {
if (!deps.artifacts || !blocks?.length) return undefined;
const imageBlocks = blocks.filter(
(block): block is Extract<MessageBlock, { kind: "image" }> => block.kind === "image",
);
if (!imageBlocks.length) return undefined;
const rows = await deps.prisma.artifact.findMany({
where: {
id: { in: imageBlocks.map((block) => block.artifactId) },
spaceId: context.spaceId,
userId: context.userId,
},
});
const byId = new Map(rows.map((row) => [row.id, row]));
const images: NonNullable<AgentRunRequest["currentTurnImages"]> = [];
for (const block of imageBlocks) {
const row = byId.get(block.artifactId);
if (!row || !isAttachmentImageMimeType(block.mimeType)) continue;
try {
const bytes = await deps.artifacts.get(row.storageKey, context);
images.push({
name: block.name,
mimeType: block.mimeType as "image/jpeg" | "image/png" | "image/webp" | "image/gif",
data: bytes,
});
} catch (error) {
if (context.signal.aborted) throw error;
}
}
return images.length ? images : undefined;
}

View File

@ -14,7 +14,6 @@ import type {
JobPublisher, JobPublisher,
ManagedConnectorProvider, ManagedConnectorProvider,
MemoryStore, MemoryStore,
NotificationMessage,
NotificationProvider, NotificationProvider,
SandboxProvider, SandboxProvider,
SemanticMemoryProvider, SemanticMemoryProvider,
@ -140,6 +139,21 @@ import {
settleUncertainEffect, settleUncertainEffect,
uncertainEffectResult, uncertainEffectResult,
} from "./approval-effect.js"; } from "./approval-effect.js";
import { recordEffect, completeEffect, uncertainEffectError } from "./executor-effects.js";
import {
computerScreenToolResult,
notifyRun,
renewRunLease,
computerRetryDelay,
computerRunRequeueData,
writeComputerRunRequeue,
requeueComputerRun,
redactBlocks,
publishMessage,
persistMessageInTransaction,
runNotificationsEnabled,
} from "./executor-computer.js";
import { import {
autoReviewTimeoutMs, autoReviewTimeoutMs,
buildAutoReviewPrompt, buildAutoReviewPrompt,
@ -203,7 +217,6 @@ import { formatCurrentTimeInstruction } from "./current-time.js";
import { resolveDeploymentModel } from "./deployment-model.js"; import { resolveDeploymentModel } from "./deployment-model.js";
import { handoffToGroupBot, loadGroupContext } from "./group-handoff.js"; import { handoffToGroupBot, loadGroupContext } from "./group-handoff.js";
import { import {
sanitizeTextBlocks,
tryRecoverProseHandoff, tryRecoverProseHandoff,
workshopFallbackAfterBlockedHandoff, workshopFallbackAfterBlockedHandoff,
} from "./prose-handoff-recovery.js"; } from "./prose-handoff-recovery.js";
@ -4378,75 +4391,6 @@ export function createRunExecutor(deps: ExecutorDeps) {
}; };
} }
async function computerScreenToolResult(
work: () => Promise<unknown>,
finish?: (result: unknown) => Promise<unknown>,
) {
const result = await withComputerScreenAvailability(work);
return finish ? finish(result) : result;
}
export async function runNotificationsEnabled(
prisma: PrismaClient,
run: { spaceId: string; userId: string; botId: string; threadId: string },
): Promise<boolean> {
const source = await prisma.run.findFirst({
where: {
botId: run.botId,
threadId: run.threadId,
spaceId: run.spaceId,
userId: run.userId,
},
select: {
bot: { select: { notifyOnFinish: true } },
thread: { select: { groupId: true } },
},
});
return Boolean(source && (source.thread.groupId || source.bot.notifyOnFinish));
}
async function notifyRun(
deps: ExecutorDeps,
run: { spaceId: string; userId: string; botId: string; threadId: string },
message: NotificationMessage,
) {
if (!deps.notifications) return;
const enabled = await runNotificationsEnabled(deps.prisma, run).catch((error) => {
getLogger().error("notification preference lookup", error);
return false;
});
if (!enabled) return;
await deps.notifications
.send(message, {
operationId: "notify",
traceId: run.botId,
spaceId: run.spaceId,
userId: run.userId,
botId: run.botId,
signal: new AbortController().signal,
})
.catch((error) => {
getLogger().error("run notification", error);
});
}
async function renewRunLease(
deps: ExecutorDeps,
runId: string,
workerId: string,
fence: number,
): Promise<boolean> {
const renewed = await deps.prisma.run.updateMany({
where: { id: runId, status: "running", leaseOwner: workerId, leaseFence: fence },
data: { leaseExpiresAt: new Date(Date.now() + 5 * 60_000) },
});
return renewed.count === 1;
}
function computerRetryDelay(fence: number): number {
return Math.min(10_000, 250 * 2 ** Math.min(Math.max(fence - 1, 0), 5));
}
export function selectBuiltinToolsForRun(options: { export function selectBuiltinToolsForRun(options: {
graphicalToolsAllowed: boolean; graphicalToolsAllowed: boolean;
/** Page browser tools need a graphical computer (Chrome), not model vision. */ /** Page browser tools need a graphical computer (Chrome), not model vision. */
@ -4494,386 +4438,6 @@ export function filterPageBrowserTools<T extends { name: string }>(
return tools.filter((tool) => !PAGE_BROWSER_TOOL_NAMES.has(tool.name)); return tools.filter((tool) => !PAGE_BROWSER_TOOL_NAMES.has(tool.name));
} }
export function threadContextForRun<T>(
trigger: string,
context: {
messages: T[];
summary: string | null;
historyCompactedUpToSeq: number | null;
},
messagingChannelRun: boolean,
) {
return trigger === "routine"
? {
messages: [] as T[],
summary: null,
historyCompactedUpToSeq: null,
includeSemanticRecall: false,
}
: messagingChannelRun
? { ...context, summary: null, historyCompactedUpToSeq: null, includeSemanticRecall: false }
: { ...context, includeSemanticRecall: true };
}
export function completionMessageSegments(
segments: MessageBlock[],
options?: {
allowSilentEmpty?: boolean;
emptyResponseText?: string;
suppressOutput?: boolean;
skipEmptyFallback?: boolean;
},
): MessageBlock[] {
if (options?.suppressOutput) return [];
const fallback = options?.emptyResponseText?.trim() || "done.";
if (segments.length > 0) {
if (
!options?.allowSilentEmpty &&
options?.emptyResponseText !== undefined &&
!segments.some((segment) => segment.kind === "text" && segment.text)
) {
return [...segments, { kind: "text", text: fallback }];
}
return segments;
}
if (options?.allowSilentEmpty || options?.skipEmptyFallback) return [];
return [{ kind: "text", text: fallback }];
}
/** User-facing text for completion notifications; empty when only tool/step activity remains. */
export function completionNotificationBody(assembled: string, blocks: MessageBlock[]): string {
if (assembled) return assembled;
return blocks
.filter((block): block is Extract<MessageBlock, { kind: "text" }> => block.kind === "text")
.map((block) => block.text)
.join("");
}
export function completionMarksUnread(trigger: string, text: string): boolean {
return trigger !== "routine" || Boolean(text);
}
export function missingTurnImagesInstruction(
blocks: MessageBlock[] | undefined,
images: { length: number } | undefined,
): string {
const expected = blocks?.filter((block) => block.kind === "image").length ?? 0;
const loaded = images?.length ?? 0;
return expected > 0 && loaded < expected ? TURN_ATTACHMENT_UNAVAILABLE : "";
}
export async function settleSteeringAttachmentLoads<TImage, TFile>(
images: Promise<TImage[] | undefined>,
files: Promise<TFile[]>,
blocks?: MessageBlock[],
signal?: AbortSignal,
): Promise<{
images: TImage[] | undefined;
files: TFile[];
unavailableInstruction: string;
}> {
const [loadedImages, loadedFiles] = await Promise.allSettled([images, files]);
if (signal?.aborted) {
if (loadedImages.status === "rejected") throw loadedImages.reason;
if (loadedFiles.status === "rejected") throw loadedFiles.reason;
}
const expectedImageCount = blocks?.filter((block) => block.kind === "image").length ?? 0;
const loadedImageCount =
loadedImages.status === "fulfilled" ? (loadedImages.value?.length ?? 0) : 0;
const unavailable =
loadedImages.status === "rejected" ||
loadedFiles.status === "rejected" ||
loadedImageCount < expectedImageCount;
return {
images: loadedImages.status === "fulfilled" ? loadedImages.value : undefined,
files: loadedFiles.status === "fulfilled" ? loadedFiles.value : [],
unavailableInstruction: unavailable ? STEERING_ATTACHMENT_UNAVAILABLE : "",
};
}
export function subagentMarksUnread(trigger: string, status: "running" | "completed" | "failed") {
return status === "failed" || trigger !== "routine";
}
function computerRunRequeueData(
resumeCheckpoint: TakeoverResumeCheckpoint | null,
error: string | null = null,
heldForTakeover = false,
) {
return {
status:
heldForTakeover && !resumeCheckpoint ? ("waiting_takeover" as const) : ("queued" as const),
error,
leaseOwner: null,
leaseExpiresAt: null,
checkpoint: resumeCheckpoint,
};
}
async function writeComputerRunRequeue(
deps: ExecutorDeps,
runId: string,
workerId: string,
fence: number,
resumeCheckpoint: TakeoverResumeCheckpoint | null,
heldForTakeover = false,
error: string | null = null,
): Promise<boolean> {
const whereLease = {
id: runId,
status: "running" as const,
leaseOwner: workerId,
leaseFence: fence,
};
const releasedHold = {
status: "queued" as const,
error,
leaseOwner: null,
leaseExpiresAt: null,
};
const preserve = await deps.prisma.run.updateMany({
where: {
...whereLease,
checkpoint: { in: [...TAKEOVER_RESUME_CHECKPOINTS] },
},
data: releasedHold,
});
if (preserve.count === 1) return true;
const planned = await deps.prisma.run.updateMany({
where: { ...whereLease, checkpoint: null },
data: computerRunRequeueData(resumeCheckpoint, error, heldForTakeover),
});
if (planned.count === 1) return true;
const retried = await deps.prisma.run.updateMany({
where: {
...whereLease,
checkpoint: { in: [...TAKEOVER_RESUME_CHECKPOINTS] },
},
data: releasedHold,
});
return retried.count === 1;
}
async function requeueComputerRun(
deps: ExecutorDeps,
runId: string,
workerId: string,
fence: number,
resumeCheckpoint: TakeoverResumeCheckpoint | null,
heldForTakeover = false,
): Promise<void> {
const released = await writeComputerRunRequeue(
deps,
runId,
workerId,
fence,
resumeCheckpoint,
heldForTakeover,
);
if (!released) return;
await deps.jobs.enqueue({
...runContinueJob(runId),
availableAt: new Date(Date.now() + computerRetryDelay(fence)),
});
}
function redactBlocks(blocks: MessageBlock[], secrets: string[]): MessageBlock[] {
return sanitizeTextBlocks(
blocks.map((block) => {
if (block.kind === "text") {
return { kind: "text" as const, text: redactSecrets(block.text, secrets) };
}
if (block.kind === "bot_message_sent" || block.kind === "bot_message_received") {
return { ...block, text: redactSecrets(block.text, secrets) };
}
return block;
}),
);
}
async function publishMessage(
deps: ExecutorDeps,
run: { id: string; spaceId: string; threadId: string; botId: string },
role: "user" | "bot" | "system",
blocks: MessageBlock[],
markUnread?: boolean,
clientNonce?: string,
) {
const committed = await deps.prisma.$transaction((tx) =>
persistMessageInTransaction(tx, run, role, blocks, markUnread, clientNonce),
);
await deps.events.notify(run.threadId, committed.eventSeq).catch((error) => {
getLogger().error("thread message realtime notification", error);
});
return committed.message;
}
async function persistMessageInTransaction(
tx: Prisma.TransactionClient,
run: { id: string; spaceId: string; threadId: string; botId: string },
role: "user" | "bot" | "system",
blocks: MessageBlock[],
markUnread?: boolean,
clientNonce?: string,
) {
const message = await createThreadMessageInTransaction(tx, {
threadId: run.threadId,
role,
blocks,
botId: run.botId,
runId: run.id,
markUnread,
clientNonce,
});
const event = await appendEventInTransaction(tx, {
spaceId: run.spaceId,
threadId: run.threadId,
botId: run.botId,
type: "thread.message.created",
runId: run.id,
payload: { messageId: message.id, role, blocks },
});
return { message, eventSeq: event.seq };
}
async function recordEffect(
deps: ExecutorDeps,
run: { id: string; spaceId: string; threadId: string; botId: string },
kind: string,
idempotencyKey: string,
request: unknown,
legacyIdempotencyKey?: string,
consumedIds?: Set<string>,
) {
const existing = await deps.prisma.externalEffect.findUnique({
where: { idempotencyKey },
});
if (existing) {
consumedIds?.add(existing.id);
await deps.events.append({
spaceId: run.spaceId,
threadId: run.threadId,
botId: run.botId,
type: "effect.reconciled",
runId: run.id,
payload: { executionId: idempotencyKey, kind },
});
return { duplicate: true, effect: existing };
}
// Pre-fix rows used bare provider ids or scoped keys that included the
// ephemeral model tool-call id. Same-id unique lookup still works; a restart
// with a new id finds the row by run, tool, and request instead.
let expectedRequest: string | undefined;
try {
expectedRequest = stableJsonValue(request);
} catch {
expectedRequest = undefined;
}
if (legacyIdempotencyKey && legacyIdempotencyKey !== idempotencyKey && expectedRequest) {
const scopedLegacy =
request && typeof request === "object" && !Array.isArray(request)
? legacyScopedToolEffectIdempotencyKey(
run.id,
kind,
legacyIdempotencyKey,
request as Record<string, unknown>,
)
: undefined;
for (const candidate of [scopedLegacy, legacyIdempotencyKey]) {
if (!candidate || candidate === idempotencyKey) continue;
const sameIdLegacy = await deps.prisma.externalEffect.findUnique({
where: { idempotencyKey: candidate },
});
if (
sameIdLegacy &&
!consumedIds?.has(sameIdLegacy.id) &&
sameIdLegacy.runId === run.id &&
sameIdLegacy.kind === kind &&
stableJsonValue(sameIdLegacy.request) === expectedRequest
) {
consumedIds?.add(sameIdLegacy.id);
await deps.events.append({
spaceId: run.spaceId,
threadId: run.threadId,
botId: run.botId,
type: "effect.reconciled",
runId: run.id,
payload: { executionId: candidate, kind, legacy: true },
});
return { duplicate: true, effect: sameIdLegacy };
}
}
}
const prior = await deps.prisma.externalEffect.findMany({
where: { runId: run.id, kind },
orderBy: { createdAt: "asc" },
});
const legacy =
expectedRequest === undefined
? undefined
: prior.find((candidate) => {
if (consumedIds?.has(candidate.id)) return false;
if (candidate.idempotencyKey === idempotencyKey) return false;
if (candidate.runId !== run.id || candidate.kind !== kind) return false;
try {
if (stableJsonValue(candidate.request) !== expectedRequest) return false;
} catch {
return false;
}
// Live later occurrences use a new modern key; do not steal an earlier modern row.
return !isToolEffectIdempotencyKey(candidate.idempotencyKey, run.id, kind);
});
if (legacy) {
consumedIds?.add(legacy.id);
await deps.events.append({
spaceId: run.spaceId,
threadId: run.threadId,
botId: run.botId,
type: "effect.reconciled",
runId: run.id,
payload: { executionId: legacy.idempotencyKey, kind, legacy: true },
});
return { duplicate: true, effect: legacy };
}
const effect = await deps.prisma.externalEffect.create({
data: {
spaceId: run.spaceId,
runId: run.id,
kind,
idempotencyKey,
status: "intended",
request: request as never,
},
});
consumedIds?.add(effect.id);
return { duplicate: false, effect };
}
async function completeEffect(
deps: ExecutorDeps,
effectId: string,
expectedStatus: "intended" | "executing",
result: unknown,
) {
const storedResult =
result &&
typeof result === "object" &&
(result as { kind?: unknown }).kind === "agent_tool_result" &&
"details" in result
? (result as { details: unknown }).details
: result;
return completeExternalEffect(deps.prisma, effectId, expectedStatus, storedResult as never);
}
function uncertainEffectError(toolName: string): Error {
return new Error(
`tool ${toolName} has an earlier execution with an uncertain outcome; it may already have completed, so verify the destination before retrying`,
);
}
async function runSandboxCommand( async function runSandboxCommand(
sandbox: SandboxProvider, sandbox: SandboxProvider,
computer: ComputerRef, computer: ComputerRef,
@ -5115,3 +4679,19 @@ export async function loadCurrentTurnImages(
return images.length ? images : undefined; return images.length ? images : undefined;
} }
// Re-export computer/lifecycle utilities from executor-computer.ts
export {
computerScreenToolResult,
notifyRun,
renewRunLease,
computerRetryDelay,
computerRunRequeueData,
writeComputerRunRequeue,
requeueComputerRun,
redactBlocks,
publishMessage,
runNotificationsEnabled,
} from "./executor-computer.js";
// Re-export effect utilities from executor-effects.ts
export { recordEffect, completeEffect, uncertainEffectError } from "./executor-effects.js";

View File

@ -47,6 +47,12 @@ export * from "./e2b-sandbox.js";
export * from "./elevenlabs-voice.js"; export * from "./elevenlabs-voice.js";
export * from "./email-emulator.js"; export * from "./email-emulator.js";
export * from "./executor.js"; export * from "./executor.js";
export * from "./executor-attachments.js";
export * from "./executor-checkpoint.js";
export * from "./executor-completion.js";
export * from "./executor-computer.js";
export * from "./executor-effects.js";
export * from "./executor-model.js";
export * from "./expo-push.js"; export * from "./expo-push.js";
export * from "./fake-browser.js"; export * from "./fake-browser.js";
export * from "./fake-sandbox.js"; export * from "./fake-sandbox.js";
@ -79,11 +85,13 @@ export * from "./openai-compatible-url.js";
export * from "./openai-voice.js"; export * from "./openai-voice.js";
export * from "./page-browser-session.js"; export * from "./page-browser-session.js";
export * from "./pi-credentials.js"; export * from "./pi-credentials.js";
export * from "./pi-model-config.js";
export * from "./pi-models.js"; export * from "./pi-models.js";
export * from "./pi-oauth.js"; export * from "./pi-oauth.js";
export * from "./pi-openai-compatible-provider.js"; export * from "./pi-openai-compatible-provider.js";
export * from "./pi-runtime.js"; export * from "./pi-runtime.js";
export * from "./pi-session.js"; export * from "./pi-session.js";
export * from "./pi-tool-utils.js";
export * from "./pipedream-connector.js"; export * from "./pipedream-connector.js";
export * from "./realtime.js"; export * from "./realtime.js";
export * from "./release-watch.js"; export * from "./release-watch.js";

View File

@ -0,0 +1,709 @@
import { randomUUID } from "node:crypto";
import {
clampThinkingLevel,
type Api,
type Model,
type ModelThinkingLevel,
type Models,
type SimpleStreamOptions,
Type,
} from "@earendil-works/pi-ai";
import { builtinModels } from "@earendil-works/pi-ai/providers/all";
import { type Agent, type AgentToolResult } from "@earendil-works/pi-agent-core";
import {
type AgentMessage,
type AgentToolExecutionResult,
} from "@earendil-works/pi-agent-core";
import type {
AgentRuntimeEvent,
AgentRunRequest,
AgentSteeringMessage,
} from "@rakazo/adapter-kit";
import { getLogger } from "@rakazo/logging";
import { DEFAULT_OPENROUTER_MODEL_ID } from "./deployment-model.js";
import {
OPENAI_COMPATIBLE_PROVIDER_ID,
registerOpenAiCompatibleCatalog,
registerOpenAiCompatibleRuntime,
} from "./pi-openai-compatible-provider.js";
import { PiRuntimeCredentialStore, toOAuthCredential } from "./pi-credentials.js";
import { registerLocalProvider } from "./pi-local-provider.js";
import {
billedPromptTokens,
clipToolResultContent,
clipToolResultText,
MODEL_STREAM_MAX_RETRIES,
MODEL_STREAM_TIMEOUT_MS,
resolveCompletionMaxTokens,
} from "./pi-runtime-limits.js";
// --- Model catalog ---
const REASONING_MODEL_THINKING_LEVEL: ModelThinkingLevel = "medium";
let catalogModelsCache: Models | undefined;
export function catalogModels(): Models {
catalogModelsCache ??= registerOpenAiCompatibleCatalog(registerLocalProvider(builtinModels()));
return catalogModelsCache;
}
export function thinkingLevelFor(
model: Model<Api>,
preferred?: ModelThinkingLevel | null,
): ModelThinkingLevel {
if (!model.reasoning) return "off";
if (preferred) return clampThinkingLevel(model, preferred);
return clampThinkingLevel(model, REASONING_MODEL_THINKING_LEVEL);
}
// --- Tool call budget ---
export interface ToolCallBudget {
count: number;
exceeded: boolean;
limit: number;
inFlight: number;
}
// Optional fuse is process-local. continueRun on another worker starts at zero.
const toolCallBudgetsByRun = new Map<string, ToolCallBudget>();
/** Optional self-host fuse. Unset, empty, or 0 means unlimited (default). */
export function maxToolCallsPerTurn(env: NodeJS.ProcessEnv = process.env): number {
const raw = env.MAX_TOOL_CALLS_PER_TURN?.trim();
if (!raw) return 0;
const parsed = Number(raw);
if (!Number.isFinite(parsed) || parsed <= 0) return 0;
return Math.floor(parsed);
}
function toolCallBudgetFor(runId: string): ToolCallBudget {
const existing = toolCallBudgetsByRun.get(runId);
if (existing) {
existing.inFlight = 0;
existing.limit = maxToolCallsPerTurn();
return existing;
}
const budget: ToolCallBudget = {
count: 0,
exceeded: false,
limit: maxToolCallsPerTurn(),
inFlight: 0,
};
if (budget.limit > 0) toolCallBudgetsByRun.set(runId, budget);
return budget;
}
export function releaseToolCallBudget(runId: string, keepForResume: boolean) {
if (!keepForResume) toolCallBudgetsByRun.delete(runId);
}
function maybeAbortToolCallBudget(host: {
toolCallBudget: ToolCallBudget;
abortTurn(): void;
}): void {
if (host.toolCallBudget.exceeded && host.toolCallBudget.inFlight === 0) {
host.abortTurn();
}
}
export function beginToolCall(host: {
toolCallBudget: ToolCallBudget;
abortTurn(): void;
queue: { push(event: { type: string; text: string }): void };
}): boolean {
const budget = host.toolCallBudget;
if (budget.limit <= 0) {
budget.count += 1;
return true;
}
if (budget.exceeded) {
maybeAbortToolCallBudget(host);
return false;
}
budget.count += 1;
if (budget.count <= budget.limit) {
budget.inFlight += 1;
return true;
}
if (!budget.exceeded) {
budget.exceeded = true;
host.queue.push({
type: "progress",
text: `Stopped: more than ${budget.limit} tool calls in one turn.`,
});
}
maybeAbortToolCallBudget(host);
return false;
}
export function endToolCall(host: {
toolCallBudget: ToolCallBudget;
abortTurn(): void;
}): void {
host.toolCallBudget.inFlight = Math.max(0, host.toolCallBudget.inFlight - 1);
maybeAbortToolCallBudget(host);
}
export function toolCallBudgetExceededMessage(limit: number): string {
return `I stopped after reaching the limit of ${limit} tool calls in this turn. Send another message to continue.`;
}
// --- Model resolution ---
function configuredOpenRouterModel(id: string): Model<"openai-completions"> {
return {
id,
name: id,
api: "openai-completions",
provider: "openrouter",
baseUrl: "https://openrouter.ai/api/v1",
reasoning: true,
input: ["text"],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: 16_384,
maxTokens: 4_096,
};
}
export function modelsForRequest(
request: Pick<AgentRunRequest, "model">,
provider: string,
): Models {
const oauth = request.model.oauth;
if (oauth) {
const persist = oauth.persist;
return registerOpenAiCompatibleCatalog(
registerLocalProvider(
builtinModels({
credentials: new PiRuntimeCredentialStore(
provider,
toOAuthCredential(oauth.credential),
persist ? (next) => persist(next) : undefined,
),
}),
),
);
}
if (
provider === OPENAI_COMPATIBLE_PROVIDER_ID &&
request.model.baseUrl &&
request.model.id.trim()
) {
const models = registerOpenAiCompatibleCatalog(registerLocalProvider(builtinModels()));
return registerOpenAiCompatibleRuntime(models, {
modelId: request.model.id,
baseUrl: request.model.baseUrl,
reasoning: request.model.reasoning,
acceptsImages: request.model.acceptsImages,
maxTokens: request.model.maxTokens,
contextWindow: request.model.contextWindow,
});
}
return catalogModels();
}
function resolveRuntimeModel(modelConfig: AgentRunRequest["model"]): {
provider: string;
modelId: string;
models: Models;
model: Model<Api> | undefined;
apiKey: string | undefined;
} {
const provider = modelConfig.provider === "scripted" ? "openrouter" : modelConfig.provider;
const envDefaultModel = process.env.PI_DEFAULT_MODEL?.trim();
const envDefaultProvider = process.env.PI_DEFAULT_PROVIDER?.trim() || "openrouter";
const modelId =
modelConfig.id === "scripted"
? envDefaultModel || DEFAULT_OPENROUTER_MODEL_ID
: modelConfig.id.trim();
const models = modelsForRequest({ model: modelConfig }, provider);
let model = models.getModel(provider, modelId);
if (!model && provider !== "openrouter" && provider !== OPENAI_COMPATIBLE_PROVIDER_ID) {
model = models.getModel("openrouter", modelId);
}
if (
!model &&
provider === "openrouter" &&
envDefaultProvider === "openrouter" &&
modelId === envDefaultModel
) {
model = configuredOpenRouterModel(modelId);
}
const apiKey = modelConfig.oauth
? undefined
: modelConfig.provider === OPENAI_COMPATIBLE_PROVIDER_ID
? modelConfig.apiKey || "local"
: (modelConfig.apiKey ??
(provider === "openrouter" ? process.env.OPENROUTER_API_KEY : undefined));
return { provider, modelId, models, model, apiKey };
}
// --- Image helpers ---
function toPiImages(images: AgentRunRequest["currentTurnImages"]) {
return (images ?? []).map((image) => ({
type: "image" as const,
data: Buffer.from(image.data).toString("base64"),
mimeType: image.mimeType,
}));
}
// --- Steering message removal ---
function withoutSteeringMessages(
history: AgentRunRequest["history"],
steering: AgentSteeringMessage[],
): AgentRunRequest["history"] {
if (steering.length === 0) return history;
const result = [...history];
let beforeIndex = result.length - 1;
for (let steeringIndex = steering.length - 1; steeringIndex >= 0; steeringIndex -= 1) {
const steeringMessage = steering[steeringIndex];
for (let index = beforeIndex; index >= 0; index -= 1) {
const message = result[index];
if (
message?.role !== "user" ||
(message.id
? message.id !== steeringMessage?.messageId
: message.content !== (steeringMessage?.historyText ?? steeringMessage?.text))
) {
continue;
}
result.splice(index, 1);
beforeIndex = index - 1;
break;
}
}
return result;
}
// --- Request secret arguments ---
export function prepareRequestSecretArguments(raw: Record<string, unknown>) {
return {
label: String(raw.label ?? "Code"),
purpose: String(raw.purpose ?? "otp"),
...(raw.connectionId ? { connectionId: String(raw.connectionId) } : {}),
...(raw.credential ? { credential: raw.credential } : {}),
...(raw.replace === true ? { replace: true } : {}),
};
}
// --- Screenshot context pruning ---
const DEFAULT_COMPUTER_SCREENSHOTS_TO_KEEP = 2;
export function pruneComputerScreenshotContext(
messages: AgentMessage[],
maxImagesPerPrompt?: number,
): AgentMessage[] {
const imageLimit =
maxImagesPerPrompt === undefined
? undefined
: Number.isFinite(maxImagesPerPrompt)
? Math.max(0, Math.floor(maxImagesPerPrompt))
: DEFAULT_COMPUTER_SCREENSHOTS_TO_KEEP;
let remaining = imageLimit ?? DEFAULT_COMPUTER_SCREENSHOTS_TO_KEEP;
if (imageLimit !== undefined) {
const nonScreenshotImages = messages.reduce(
(count, message) =>
isComputerScreenshotMessage(message) ? count : count + imagePartCount(message),
0,
);
if (nonScreenshotImages > imageLimit) {
throw new Error(
`The configured model image limit is ${imageLimit}, but the prompt contains ${nonScreenshotImages} non-screenshot images.`,
);
}
remaining = imageLimit - nonScreenshotImages;
}
let transformed: AgentMessage[] | undefined;
for (let index = messages.length - 1; index >= 0; index -= 1) {
const message = messages[index];
if (!isComputerScreenshotMessage(message)) continue;
const images = imagePartCount(message);
if (remaining >= images) {
remaining -= images;
continue;
}
transformed ??= [...messages];
transformed[index] = {
...message,
content: message.content.filter((part) => part.type !== "image"),
};
}
return transformed ?? messages;
}
function imagePartCount(message: AgentMessage): number {
if (!("content" in message) || !Array.isArray(message.content)) return 0;
return message.content.filter(
(part: unknown) =>
typeof part === "object" && part !== null && "type" in part && (part as {type?: string}).type === "image",
).length;
}
function isComputerScreenshotMessage(
message: AgentMessage | undefined,
): message is Extract<AgentMessage, { role: "toolResult" }> {
if (message?.role !== "toolResult" || !message.content.some((part) => (part as {type?: string}).type === "image")) {
return false;
}
const details = message.details;
return Boolean(
details &&
typeof details === "object" &&
"frameId" in details &&
typeof (details as { frameId?: unknown }).frameId === "string",
);
}
// --- Type guards ---
function isAgentToolExecutionResult(result: unknown): result is AgentToolExecutionResult {
if (
!result ||
typeof result !== "object" ||
(result as { kind?: unknown }).kind !== "agent_tool_result" ||
!("content" in result)
) {
return false;
}
const content = (result as { content?: unknown }).content;
return (
Array.isArray(content) &&
content.every(
(item) =>
item &&
typeof item === "object" &&
((item as { type?: unknown }).type === "text" ||
(item as { type?: unknown }).type === "image"),
)
);
}
// --- JSON Schema helpers ---
export function jsonSchemaParameters(
schema: Record<string, unknown>,
): ReturnType<typeof Type.Object> {
const alternatives = Array.isArray(schema.oneOf)
? schema.oneOf
: Array.isArray(schema.anyOf)
? schema.anyOf
: undefined;
if (alternatives && alternatives.length > 0 && schema.properties == null) {
return Type.Union(
alternatives.map((variant) => jsonSchemaParameters(variant as Record<string, unknown>)),
) as unknown as ReturnType<typeof Type.Object>;
}
const properties = (schema.properties ?? {}) as Record<string, unknown>;
const required = new Set(Array.isArray(schema.required) ? schema.required.map(String) : []);
const fields: Record<string, ReturnType<typeof Type.Optional>> = {};
for (const [key, spec] of Object.entries(properties)) {
const field = jsonField(spec);
fields[key] = (required.has(key) ? field : Type.Optional(field)) as unknown as ReturnType<
typeof Type.Optional
>;
}
return schema.additionalProperties === false
? Type.Object(fields, { additionalProperties: false })
: Type.Object(fields);
}
function enumUnion(values: readonly unknown[]) {
const members = values.map((value) =>
value === null
? Type.Null()
: typeof value === "string" || typeof value === "number" || typeof value === "boolean"
? Type.Literal(value)
: undefined,
);
return members.every((member) => member !== undefined) ? Type.Union(members) : undefined;
}
export function jsonField(spec: unknown): ReturnType<typeof Type.String> {
const definition = spec && typeof spec === "object" ? (spec as Record<string, unknown>) : {};
if (Array.isArray(definition.enum) && definition.enum.length > 0) {
const union = enumUnion(definition.enum);
if (union) return union as never;
}
if ("const" in definition) {
const literal = enumUnion([definition.const]);
if (literal) return literal as never;
}
const variants = Array.isArray(definition.oneOf)
? definition.oneOf
: Array.isArray(definition.anyOf)
? definition.anyOf
: undefined;
if (variants && variants.length > 0) {
return Type.Union(variants.map((variant) => jsonField(variant))) as never;
}
if (Array.isArray(definition.type) && definition.type.length > 0) {
return Type.Union(definition.type.map((type) => jsonField({ ...definition, type }))) as never;
}
const type = "type" in definition ? String(definition.type) : "string";
if (type === "null") return Type.Null() as never;
if (type === "number" || type === "integer") return Type.Number() as never;
if (type === "boolean") return Type.Boolean() as never;
if (type === "array") {
const options: {
minItems?: number;
maxItems?: number;
uniqueItems?: boolean;
} = {};
if (typeof definition.minItems === "number") options.minItems = definition.minItems;
if (typeof definition.maxItems === "number") options.maxItems = definition.maxItems;
if (definition.uniqueItems === true) options.uniqueItems = true;
return Type.Array(jsonField(definition.items), options) as never;
}
if (type === "object") return jsonSchemaParameters(definition) as never;
return Type.String();
}
// --- Result helpers ---
function summarizeToolResult(result: unknown) {
try {
const text = JSON.stringify(result);
if (!text) return "ok";
return clipToolResultText(text);
} catch {
return "ok";
}
}
function boundAgentToolResult<T>(
result: AgentToolResult<T>,
): AgentToolResult<T> {
if (!result || !Array.isArray(result.content)) return result;
return {
...result,
content: clipToolResultContent(result.content),
};
}
function assistantText(message: unknown): string {
if (!message || typeof message !== "object" || !("content" in message)) return "";
const content = (message as { content?: unknown }).content;
if (typeof content === "string") return content;
if (!Array.isArray(content)) return "";
return content
.map((part) =>
part &&
typeof part === "object" &&
"type" in part &&
part.type === "text" &&
"text" in part
? String(part.text)
: "",
)
.join("");
}
// --- Sanitization ---
function sanitizeSensitiveText(message: string) {
return message
.replace(/sk-or-v1-[a-zA-Z0-9]+/g, "[redacted]")
.replace(/sk-[a-zA-Z0-9-]+/g, "[redacted]")
.replace(/Bearer\s+[^\s"',;&]+/gi, "Bearer [redacted]")
.replace(/eyJ[a-zA-Z0-9_-]+\.[a-zA-Z0-9_-]+\.[a-zA-Z0-9_-]+/g, "[redacted]")
.replace(
/COMPOSIO_API_KEY[=:]?\s*\S+/gi,
"COMPOSIO_API_KEY=[redacted]",
)
.replace(
/((?:api[_-]?key|access[_-]?token|password|secret)\s*[=:]\s*)[^\s"',;&]+/gi,
"$1[redacted]",
)
.replace(
/((?:auth|authorization)\s*[=:]\s*)(?!Bearer\b)[^\s"',;&]+/gi,
"$1[redacted]",
);
}
function redactActivityUrl(value: unknown): string {
const raw = String(value ?? "").trim();
if (!raw) return raw;
try {
const url = new URL(raw);
url.username = "";
url.password = "";
url.search = "";
url.hash = "";
return url.toString();
} catch {
return "[invalid URL]";
}
}
function sanitizeError(message: string) {
return sanitizeSensitiveText(message);
}
// --- Session ID & provider helpers ---
export function conversationSessionId(
threadId: string,
botId: string,
agentId?: string,
): string {
return agentId ? `${threadId}:${botId}:${agentId}` : `${threadId}:${botId}`;
}
export function isOpenCodeProvider(provider: string): boolean {
return provider === "opencode" || provider === "opencode-go";
}
const OPENCODE_SESSION_ERROR =
"OpenCode rejected this chat session. Send the message again.";
function looksLikeOpenCodeSessionError(message: string): boolean {
return (
/x-opencode-session/i.test(message) ||
/session\s*(id|header|required|missing|invalid|expired|stale)/i.test(message) ||
/model is unavailable/i.test(message)
);
}
function sanitizeProviderError(provider: string, message: string): string {
const sanitized = sanitizeError(message);
if (isOpenCodeProvider(provider) && looksLikeOpenCodeSessionError(sanitized)) {
return OPENCODE_SESSION_ERROR;
}
return sanitized;
}
// --- Queue & gate ---
interface EventQueue {
push(event: AgentRuntimeEvent): void;
fail(error: Error): void;
close(): void;
iterate(): AsyncIterable<AgentRuntimeEvent>;
}
export function createQueue(): EventQueue {
const items: AgentRuntimeEvent[] = [];
let wake: (() => void) | undefined;
let closed = false;
let failure: Error | undefined;
return {
push(event) {
items.push(event);
wake?.();
},
fail(error) {
failure = error;
closed = true;
wake?.();
},
close() {
closed = true;
wake?.();
},
async *iterate() {
while (true) {
if (items.length) {
yield items.shift()!;
continue;
}
if (failure) throw failure;
if (closed) return;
await new Promise<void>((resolve) => {
wake = resolve;
});
}
},
};
}
export function createGate(max: number) {
let active = 0;
const waiters: Array<() => void> = [];
return {
async acquire() {
if (active >= max) {
await new Promise<void>((resolve) => {
waiters.push(resolve);
});
}
active += 1;
},
release() {
active = Math.max(0, active - 1);
waiters.shift()?.();
},
};
}
// --- Model completion ---
export function modelForCompletion(
model: Model<Api>,
configuredMaxTokens?: number,
): Model<Api> {
const maxTokens = resolveCompletionMaxTokens(model.maxTokens, configuredMaxTokens);
if (maxTokens === model.maxTokens) return model;
return { ...model, maxTokens };
}
// --- Reliable streaming ---
export function reliableStreamOptions(
model: Pick<Model<Api>, "api" | "provider" | "maxTokens">,
options?: SimpleStreamOptions,
configuredMaxTokens?: number,
): SimpleStreamOptions {
let next: SimpleStreamOptions = {
...options,
timeoutMs: options?.timeoutMs ?? MODEL_STREAM_TIMEOUT_MS,
maxRetries: options?.maxRetries ?? MODEL_STREAM_MAX_RETRIES,
maxTokens: resolveCompletionMaxTokens(
model.maxTokens,
configuredMaxTokens,
options?.maxTokens,
),
};
if (model.provider === "openai-codex" || model.api === "openai-codex-responses") {
next = { ...next, transport: "sse" };
}
if (isOpenCodeProvider(model.provider)) {
const sessionId = next.sessionId?.trim() || randomUUID();
next = {
...next,
sessionId,
headers: {
"x-opencode-session": sessionId,
"x-opencode-client": "rakazo",
...next.headers,
},
};
}
return next;
}
// --- Tool host interface ---
export interface ToolHost {
queue: EventQueue;
request: AgentRunRequest;
models: Models;
model: Model<Api>;
apiKey: string | undefined;
nestedAgents: Set<Agent>;
subagentGate: { acquire(): Promise<void>; release(): void };
toolCallBudget: ToolCallBudget;
toolCallSeq: { value: number };
abortTurn(): void;
signal: AbortSignal;
depth: number;
pausePending: boolean;
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,28 @@
import { createDb } from "./src/client";
process.env.DATABASE_URL = "postgres://rakazo:rakazodev@127.0.0.1:5433/rakazo";
const { prisma: db } = createDb(process.env.DATABASE_URL, { applicationName: "check-users" });
const allUsers = await db.user.findMany({
select: { id: true, email: true, name: true, createdAt: true, emailVerified: true },
orderBy: { createdAt: 'desc' },
});
console.log("All users:", JSON.stringify(allUsers, null, 2));
// Check spaces
const allSpaces = await db.space.findMany({
select: { id: true, name: true, ownerId: true, createdAt: true },
orderBy: { createdAt: 'desc' },
});
console.log("\nAll spaces:", JSON.stringify(allSpaces, null, 2));
// Check space members
const allMembers = await db.spaceMember.findMany({
select: { id: true, spaceId: true, userId: true, role: true },
});
console.log("\nAll members:", JSON.stringify(allMembers, null, 2));
await db.$disconnect();

View File

@ -0,0 +1,13 @@
import { createPool, createDb } from "./src/client.js";
const pool = createPool(process.env.DATABASE_URL, { applicationName: "query-users" });
const db = createDb(pool);
const users = await db.user.findMany({
orderBy: { createdAt: 'desc' },
take: 10,
select: { email: true, name: true, createdAt: true },
});
console.log(JSON.stringify(users, null, 2));
await db.$disconnect();

View File

@ -0,0 +1,13 @@
import { createDb } from "./src/client";
// Override to port 5433 (Docker published port)
process.env.DATABASE_URL = "postgres://rakazo:rakazodev@127.0.0.1:5433/rakazo";
const { prisma: db } = createDb(process.env.DATABASE_URL, { applicationName: "query-users" });
const users = await db.user.findMany({
orderBy: { createdAt: 'desc' },
take: 10,
select: { email: true, name: true, createdAt: true },
});
console.log(JSON.stringify(users, null, 2));

108
packages/db/setup-user.ts Normal file
View File

@ -0,0 +1,108 @@
import { createDb } from "./src/client";
import { randomUUID } from "node:crypto";
process.env.DATABASE_URL = "postgres://rakazo:rakazodev@127.0.0.1:5433/rakazo";
const { prisma: db } = createDb(process.env.DATABASE_URL, { applicationName: "setup-user" });
const user = await db.user.findUnique({
where: { email: "ingo.wagner1303@gmail.com" },
select: { id: true, name: true },
});
if (!user) {
console.log("User nicht gefunden!");
await db.$disconnect();
process.exit(1);
}
console.log("User:", user);
// Step 1: Get or create Organization
let org = await db.organization.findUnique({ where: { slug: "ingo-workspace" } });
if (!org) {
org = await db.organization.create({
data: {
id: randomUUID(),
name: "Ingo's Workspace",
slug: "ingo-workspace",
createdAt: new Date(),
},
select: { id: true, name: true, slug: true },
});
console.log("Organization created:", org);
} else {
console.log("Organization exists:", org);
}
// Step 2: Get or create Organization Member
let member = await db.member.findUnique({
where: {
organizationId_userId: { organizationId: org.id, userId: user.id },
},
});
if (!member) {
member = await db.member.create({
data: {
id: randomUUID(),
userId: user.id,
organizationId: org.id,
role: "owner",
createdAt: new Date(),
},
});
console.log("Organization member created");
} else {
console.log("Organization member exists");
}
// Step 3: Get or create default Space
let space = await db.space.findFirst({
where: { organizationId: org.id, isDefault: true },
});
if (!space) {
space = await db.space.create({
data: {
id: randomUUID(),
name: "Mein Space",
isDefault: true,
createdByUserId: user.id,
organizationId: org.id,
},
select: { id: true, name: true },
});
console.log("Space created:", space);
} else {
console.log("Space exists:", space);
}
// Step 4: Get or create space member
const existingMember = await db.spaceMember.findUnique({
where: {
spaceId_userId: { spaceId: space.id, userId: user.id },
},
});
if (!existingMember) {
await db.spaceMember.create({
data: {
id: randomUUID(),
spaceId: space.id,
organizationId: org.id,
userId: user.id,
role: "owner",
createdAt: new Date(),
},
});
console.log("Space member created");
} else {
console.log("Space member exists");
}
// Verify
const finalSpaces = await db.space.findMany({
where: { organization: { members: { some: { userId: user.id } } } },
select: { id: true, name: true, isDefault: true },
});
console.log("\nUser spaces:", JSON.stringify(finalSpaces, null, 2));
await db.$disconnect();
console.log("\nDone!");