Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
31 commits
Select commit Hold shift + click to select a range
c36bda0
fix: harden workspace routing and local-first gates
YOMXXX May 21, 2026
1b8523a
fix: address workspace routing review feedback
YOMXXX May 21, 2026
60c936d
fix: address CodeRabbit nitpicks on vault migration, UNC detection, a…
YOMXXX May 21, 2026
ff69d23
chore: apply Prettier auto-fix on useUsageState test
YOMXXX May 21, 2026
d46310a
test: cover forward-slash vault UNC paths
YOMXXX May 21, 2026
eba2b36
fix: serialize vault schema migration guard
YOMXXX May 21, 2026
b023009
test: isolate OpenRouter model listing tests
YOMXXX May 21, 2026
0b8bb4f
Merge remote-tracking branch 'upstream/main' into codex/gh-2437-host-…
YOMXXX May 22, 2026
331e539
Merge remote-tracking branch 'upstream/main' into codex/gh-2437-host-…
YOMXXX May 22, 2026
092b141
test: avoid nested core build in mcp stdio coverage
YOMXXX May 22, 2026
7fba5b5
Merge upstream/main into host local settings
YOMXXX May 23, 2026
28c49ce
Merge upstream/main into host local settings
YOMXXX May 23, 2026
ca0b4af
Merge remote-tracking branch 'upstream/main' into codex/gh-2437-host-…
YOMXXX May 23, 2026
dba5b65
chore: retrigger workspace routing ci
YOMXXX May 23, 2026
5362c8c
Merge remote-tracking branch 'upstream/main' into codex/gh-2437-host-…
YOMXXX May 24, 2026
f55e1cd
fix(screen-intelligence): use pii-safe memory keys
YOMXXX May 24, 2026
584ab14
test(screen-intelligence): expect pii-safe vision keys
YOMXXX May 24, 2026
9cd28e2
chore: retrigger ci
YOMXXX May 24, 2026
6d7dc38
Merge remote-tracking branch 'upstream/main' into codex/gh-2437-host-…
YOMXXX May 24, 2026
820553e
chore: retrigger ci
YOMXXX May 24, 2026
88e889f
ci: serialize rust core coverage linking
YOMXXX May 24, 2026
bbee6bf
fix: harden desktop auth callback
YOMXXX May 24, 2026
3dd0582
chore: retrigger link check
YOMXXX May 24, 2026
b204ae0
Merge branch 'main' into pr/2445
senamakel May 25, 2026
c3fd84a
fix(i18n): remove duplicate de-5 keys from merge resolution
senamakel May 25, 2026
d681219
fix(vault): restrict UNC detection to backslash-only (addresses @code…
senamakel May 25, 2026
6ca5f7f
fix: update memory module paths after main's memory layer refactor
senamakel May 25, 2026
464bdb7
fix(test): use PII-safe keys in memory learn tests
senamakel May 25, 2026
a260891
fix(test): use PII-safe IDs across all memory ops tests
senamakel May 25, 2026
f17173e
Merge remote-tracking branch 'upstream/main' into pr/2445
senamakel May 25, 2026
0d8424b
fix(test): hold workspace env lock across config write and test body
senamakel May 25, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .github/workflows/coverage.yml
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,10 @@ jobs:
image: ghcr.io/tinyhumansai/openhuman_ci:rust-1.93.0
env:
CARGO_INCREMENTAL: '0'
# Coverage instrumentation makes each test binary link substantially
# heavier. Keep the core coverage job's linker work serialized to avoid
# intermittent rust-lld bus errors on hosted Linux runners.
CARGO_BUILD_JOBS: '1'
# sccache is incompatible with `-C instrument-coverage` profiles, so we
# skip it for coverage runs and rely on Swatinem/rust-cache for warmup.
steps:
Expand Down
18 changes: 13 additions & 5 deletions app/src-tauri/src/core_process.rs
Original file line number Diff line number Diff line change
Expand Up @@ -367,15 +367,21 @@ impl CoreProcessHandle {

let port_open = self.is_rpc_port_open().await;
return Err(self
.cleanup_startup_timeout(received_ready, port_open)
.cleanup_startup_timeout(received_ready, port_open, startup_attempt + 1)
.await);
}

let port_open = self.is_rpc_port_open().await;
Err(self.cleanup_startup_timeout(false, port_open).await)
Err(self.cleanup_startup_timeout(false, port_open, 2).await)
}

async fn cleanup_startup_timeout(&self, received_ready: bool, port_open: bool) -> String {
async fn cleanup_startup_timeout(
&self,
received_ready: bool,
port_open: bool,
attempt: u8,
) -> String {
let port = self.port();
let task_state = {
let guard = self.task.lock().await;
match guard.as_ref() {
Expand All @@ -386,14 +392,16 @@ impl CoreProcessHandle {
};
log::error!(
"[core] startup timed out after {CORE_READY_TIMEOUT_MS}ms \
(ready_signal={received_ready}, port_open={port_open}, task_state={task_state}); \
(port={port}, ready_signal={received_ready}, port_open={port_open}, \
task_state={task_state}, attempt={attempt}); \
aborting embedded startup task before retry"
);
self.cancel_shutdown_token(" after startup timeout").await;
self.abort_task(" after startup timeout").await;
format!(
"core process did not become ready within {CORE_READY_TIMEOUT_MS}ms \
(ready_signal={received_ready}, port_open={port_open}, task_state={task_state})"
(port={port}, ready_signal={received_ready}, port_open={port_open}, \
task_state={task_state}, attempt={attempt})"
)
}

Expand Down
10 changes: 9 additions & 1 deletion app/src-tauri/src/core_process_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -543,7 +543,7 @@ fn startup_timeout_cleanup_aborts_task_and_clears_slot() {
*guard = Some(task);
}

let message = handle.cleanup_startup_timeout(false, false).await;
let message = handle.cleanup_startup_timeout(false, false, 2).await;

assert!(
message.contains("core process did not become ready within"),
Expand All @@ -553,6 +553,10 @@ fn startup_timeout_cleanup_aborts_task_and_clears_slot() {
message.contains("ready_signal=false"),
"timeout message should include ready signal state: {message}"
);
assert!(
message.contains("port=19006"),
"timeout message should include RPC port: {message}"
);
assert!(
message.contains("port_open=false"),
"timeout message should include final port state: {message}"
Expand All @@ -561,6 +565,10 @@ fn startup_timeout_cleanup_aborts_task_and_clears_slot() {
message.contains("task_state=running"),
"timeout message should include task state: {message}"
);
assert!(
message.contains("attempt=2"),
"timeout message should include startup attempt: {message}"
);
assert!(
handle.task.lock().await.is_none(),
"cleanup must clear the managed task slot so retry can spawn fresh"
Expand Down
15 changes: 14 additions & 1 deletion app/src/components/composio/toolkitMeta.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,10 @@ import { composioToolkitMeta, KNOWN_COMPOSIO_TOOLKITS } from './toolkitMeta';

describe('composioToolkitMeta', () => {
it('ships the full Composio managed-auth catalog fallback', () => {
expect(KNOWN_COMPOSIO_TOOLKITS).toHaveLength(118);
expect(KNOWN_COMPOSIO_TOOLKITS).toHaveLength(119);
expect(KNOWN_COMPOSIO_TOOLKITS).toContain('gmail');
expect(KNOWN_COMPOSIO_TOOLKITS).toContain('discord');
expect(KNOWN_COMPOSIO_TOOLKITS).toContain('larksuite');
expect(KNOWN_COMPOSIO_TOOLKITS).toContain('supabase');
expect(KNOWN_COMPOSIO_TOOLKITS).toContain('zoom');
});
Expand All @@ -24,6 +25,18 @@ describe('composioToolkitMeta', () => {
expect(calendar.logoUrl).toContain('/googlecalendar');
});

it('normalizes Lark and Feishu aliases to the LarkSuite toolkit for Chinese workplace coverage (#2148)', () => {
const larksuite = composioToolkitMeta('larksuite');
const lark = composioToolkitMeta('lark');
const feishu = composioToolkitMeta('feishu');

expect(larksuite.name).toBe('Lark / Feishu');
expect(larksuite.category).toBe('Chat');
expect(larksuite.permissionLabel).toBe('Messages, channels, and communication data');
expect(lark.slug).toBe('larksuite');
expect(feishu.slug).toBe('larksuite');
});

it('documents Instagram Business account requirement and Meta 429 guidance', () => {
const meta = composioToolkitMeta('instagram');
expect(meta.description).toMatch(/Business or Creator/i);
Expand Down
16 changes: 13 additions & 3 deletions app/src/components/composio/toolkitMeta.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,8 @@
* names, categories, descriptions, and logos for rendering.
*
* Source of truth for the managed-auth list:
* https://docs.composio.dev/toolkits/managed-auth (118 toolkits as of
* May 1, 2026).
* https://docs.composio.dev/toolkits/managed-auth plus OpenHuman's
* compatibility aliases (119 toolkits as of May 21, 2026).
*/
import { type ReactNode, useState } from 'react';

Expand Down Expand Up @@ -100,6 +100,7 @@ const MANAGED_COMPOSIO_TOOLKITS: readonly ManagedToolkitEntry[] = Object.freeze(
{ slug: 'intercom', name: 'Intercom' },
{ slug: 'jira', name: 'Jira' },
{ slug: 'kit', name: 'Kit' },
{ slug: 'larksuite', name: 'Lark / Feishu' },
{ slug: 'linear', name: 'Linear' },
{ slug: 'linkedin', name: 'LinkedIn' },
{ slug: 'linkhut', name: 'Linkhut' },
Expand Down Expand Up @@ -163,7 +164,16 @@ const MANAGED_TOOLKIT_NAME_BY_SLUG = new Map(
MANAGED_COMPOSIO_TOOLKITS.map(entry => [entry.slug, entry.name])
);

const CHAT_KEYWORDS = ['discord', 'slack', 'teams', 'webex', 'whatsapp', 'dialpad'];
const CHAT_KEYWORDS = [
'discord',
'slack',
'teams',
'webex',
'whatsapp',
'dialpad',
'lark',
'feishu',
];
const SOCIAL_KEYWORDS = [
'facebook',
'instagram',
Expand Down
88 changes: 88 additions & 0 deletions app/src/hooks/useUsageState.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,21 @@ const ALL_OPENHUMAN_AI_SETTINGS = {
},
};

const ALL_LOCAL_AI_SETTINGS = {
cloudProviders: [],
routing: {
chat: { kind: 'local' as const, model: 'qwen3:8b' },
reasoning: { kind: 'local' as const, model: 'qwen3:8b' },
agentic: { kind: 'local' as const, model: 'qwen3:8b' },
coding: { kind: 'local' as const, model: 'qwen3:8b' },
memory: { kind: 'local' as const, model: 'nomic-embed-text' },
embeddings: { kind: 'local' as const, model: 'nomic-embed-text' },
heartbeat: { kind: 'local' as const, model: 'qwen3:8b' },
learning: { kind: 'local' as const, model: 'qwen3:8b' },
subconscious: { kind: 'local' as const, model: 'qwen3:8b' },
},
};

interface BuildUsageOpts {
remainingUsd?: number;
cycleBudgetUsd?: number;
Expand Down Expand Up @@ -475,6 +490,79 @@ describe('useUsageState', () => {
expect(result.current.isAtLimit).toBe(false);
});

it('does not fetch billing usage when every workload routes away from OpenHuman (#2020)', async () => {
const { useUsageState } = await import('./useUsageState');

mockLoadAISettings.mockResolvedValue(ALL_LOCAL_AI_SETTINGS);
mockGetCurrentPlan.mockRejectedValue(new Error('billing plan should not be fetched'));
mockGetTeamUsage.mockRejectedValue(new Error('team usage should not be fetched'));

const { result } = renderHook(() => useUsageState());

await waitFor(() => {
expect(result.current.isLoading).toBe(false);
});

expect(result.current.teamUsage).toBeNull();
expect(result.current.currentPlan).toBeNull();
expect(result.current.isFullyRoutedAway).toBe(true);
expect(mockGetCurrentPlan).not.toHaveBeenCalled();
expect(mockGetTeamUsage).not.toHaveBeenCalled();
});

it('rechecks routing before returning a warm billing cache (#2020)', async () => {
const { useUsageState } = await import('./useUsageState');

mockGetCurrentPlan.mockResolvedValue(basicPlan());
mockGetTeamUsage.mockResolvedValue(buildUsage({ remainingUsd: 0, cycleBudgetUsd: 10 }));
mockLoadAISettings
.mockResolvedValueOnce(ALL_OPENHUMAN_AI_SETTINGS)
.mockResolvedValueOnce(ALL_LOCAL_AI_SETTINGS);

const first = renderHook(() => useUsageState());
await waitFor(() => {
expect(first.result.current.isLoading).toBe(false);
});
expect(first.result.current.teamUsage).not.toBeNull();
first.unmount();

mockGetCurrentPlan.mockClear();
mockGetTeamUsage.mockClear();

const second = renderHook(() => useUsageState());
await waitFor(() => {
expect(second.result.current.isLoading).toBe(false);
});

expect(second.result.current.teamUsage).toBeNull();
expect(second.result.current.currentPlan).toBeNull();
expect(second.result.current.isFullyRoutedAway).toBe(true);
expect(mockLoadAISettings).toHaveBeenCalledTimes(2);
expect(mockGetCurrentPlan).not.toHaveBeenCalled();
expect(mockGetTeamUsage).not.toHaveBeenCalled();
});

it('still fetches billing when a background workload remains on OpenHuman', async () => {
const { useUsageState } = await import('./useUsageState');

mockLoadAISettings.mockResolvedValue({
...ALL_LOCAL_AI_SETTINGS,
routing: { ...ALL_LOCAL_AI_SETTINGS.routing, memory: { kind: 'openhuman' as const } },
});
mockGetCurrentPlan.mockResolvedValue(freePlan());
mockGetTeamUsage.mockResolvedValue(buildUsage());

const { result } = renderHook(() => useUsageState());

await waitFor(() => {
expect(result.current.isLoading).toBe(false);
});

expect(result.current.isFullyRoutedAway).toBe(true);
expect(mockGetCurrentPlan).toHaveBeenCalledTimes(1);
expect(mockGetTeamUsage).toHaveBeenCalledTimes(1);
});

it('rethrows CoreRpcError(kind=auth_expired) from loadAISettings instead of swallowing it (graycyrus review on #2053)', async () => {
// The two sibling fetches (getTeamUsage, getCurrentPlan) explicitly
// re-throw auth_expired so coreRpcClient's global re-auth event fires.
Expand Down
57 changes: 35 additions & 22 deletions app/src/hooks/useUsageState.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,11 @@
import { useCallback, useEffect, useState } from 'react';

import { type AISettings, CHAT_WORKLOADS, loadAISettings } from '../services/api/aiSettingsApi';
import {
type AISettings,
ALL_WORKLOADS,
CHAT_WORKLOADS,
loadAISettings,
} from '../services/api/aiSettingsApi';
import { billingApi } from '../services/api/billingApi';
import { creditsApi, type TeamUsage } from '../services/api/creditsApi';
import { CoreRpcError } from '../services/coreRpcClient';
Expand Down Expand Up @@ -38,19 +43,45 @@ let _cache: {

const USAGE_UNAVAILABLE = Symbol('usage-unavailable');

function workloadsRoutedAway(aiSettings: AISettings, workloads: readonly string[]): boolean {
return workloads.every(w => {
const ref = aiSettings.routing[w as keyof AISettings['routing']];
return ref !== undefined && ref.kind !== 'openhuman';
});
}

async function fetchUsageData(): Promise<{
teamUsage: TeamUsage | null;
currentPlan: CurrentPlanData | null;
aiSettings: AISettings | null;
} | null> {
// Read routing first. If every workload is explicitly assigned to a local
// or user-supplied cloud provider, this session should not phone home to
// OpenHuman's billing/usage APIs at all (#2020). Missing/failed AI settings
// stay conservative and fall through to the existing billing path.
const aiSettings = await loadAISettings().catch(err => {
if (err instanceof CoreRpcError && err.kind === 'auth_expired') {
throw err;
}
return USAGE_UNAVAILABLE;
});
if (
aiSettings !== USAGE_UNAVAILABLE &&
workloadsRoutedAway(aiSettings as AISettings, ALL_WORKLOADS)
) {
return { teamUsage: null, currentPlan: null, aiSettings: aiSettings as AISettings };
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}
if (_cache && Date.now() - _cache.fetchedAt < CACHE_TTL_MS) {
return _cache.data;
return {
..._cache.data,
aiSettings: aiSettings === USAGE_UNAVAILABLE ? null : (aiSettings as AISettings),
};
}
// Wrap each leg so a single failing call (e.g. /teams returning 401 after
// session expiry) cannot reject the Promise.all microtask before the
// sibling resolves — that race let the unhandled rejection leak to the
// window's unhandledrejection trap and onward to Sentry (#1472).
const [teamUsage, currentPlan, aiSettings] = await Promise.all([
const [teamUsage, currentPlan] = await Promise.all([
creditsApi.getTeamUsage().catch(err => {
if (err instanceof CoreRpcError && err.kind === 'auth_expired') {
throw err;
Expand All @@ -63,19 +94,6 @@ async function fetchUsageData(): Promise<{
}
return USAGE_UNAVAILABLE;
}),
// AI settings drive the "routed away from openhuman" detection used to
// suppress the budget banner when the user supplied their own provider
// key (#2040 / #2041). Mirror the sibling fetches: re-throw
// CoreRpcError(kind='auth_expired') so the documented session-expired
// signal still reaches the global re-auth handler (graycyrus review on
// #2053). Other failures are treated as "unknown" — the budget gate
// stays in its conservative (banner-on) state.
loadAISettings().catch(err => {
if (err instanceof CoreRpcError && err.kind === 'auth_expired') {
throw err;
}
return USAGE_UNAVAILABLE;
}),
]);
const data = {
teamUsage: teamUsage === USAGE_UNAVAILABLE ? null : (teamUsage as TeamUsage),
Expand Down Expand Up @@ -154,12 +172,7 @@ export function useUsageState(): UsageState {
// user. Conservative on missing aiSettings (treat as still using
// openhuman) so we never silently disable the gate after a transient
// fetch failure (#2040, #2041).
const isFullyRoutedAway = aiSettings
? CHAT_WORKLOADS.every(w => {
const ref = aiSettings.routing[w];
return ref !== undefined && ref.kind !== 'openhuman';
})
: false;
const isFullyRoutedAway = aiSettings ? workloadsRoutedAway(aiSettings, CHAT_WORKLOADS) : false;

const rawBudgetExhausted = teamUsage
? teamUsage.cycleBudgetUsd > 0.01 && teamUsage.remainingUsd <= 0.01
Expand Down
2 changes: 2 additions & 0 deletions app/src/lib/composio/toolkitSlug.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
const TOOLKIT_ALIASES: Record<string, string> = {
feishu: 'larksuite',
google_calendar: 'googlecalendar',
google_drive: 'googledrive',
google_sheets: 'googlesheets',
lark: 'larksuite',
};

export function canonicalizeComposioToolkitSlug(slug: string): string {
Expand Down
Loading
Loading