Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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 packages/api/routes/instanceCatalogRoutes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,10 @@ function catalogAgent(agent: AgentConfig): InstanceCatalogAgent {
alias: agent.alias,
enabled: true,
supportedModels: [...agent.supportedModels],
// The goal-control integration replaces these fail-closed values with its
// explicit agent discriminator and model-catalog intersection.
goalCapable: false,
goalCapableModels: [],
...(agent.defaultModel ? { defaultModel: agent.defaultModel } : {}),
};
}
Expand Down
2 changes: 2 additions & 0 deletions packages/api/test/instanceAuthorization.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -278,6 +278,8 @@ describe('instance catalog', () => {
alias: 'default',
enabled: true,
supportedModels: ['gpt-5.4'],
goalCapable: false,
goalCapableModels: [],
defaultModel: 'gpt-5.4'
}],
repositories: [{
Expand Down
15 changes: 14 additions & 1 deletion packages/shared/src/events.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,9 @@ export const TASK_LIVE_UPDATE = 'task:live:update';
/** Event fired when queue statistics change */
export const QUEUE_STATS_UPDATE = 'queue:stats:update';

/** Versioned invalidation for an owned goal summary/list projection. */
export const GOAL_SUMMARY_UPDATE = 'goal:summary:update';

/** Redis channel names for pub/sub */
export const REDIS_CHANNELS = {
/** Channel for all task-related events */
Expand Down Expand Up @@ -166,6 +169,15 @@ export interface QueueStatsUpdatePayload {
timestamp: string;
}

export interface GoalSummaryUpdatePayload {
eventType: typeof GOAL_SUMMARY_UPDATE;
schemaVersion: 1;
goalId: string;
version: number;
latestSequence: number;
timestamp: string;
}

/** Command mode for slash-command-driven tasks */
export type CommandMode = 'default' | 'review' | 'fix';

Expand All @@ -176,4 +188,5 @@ export type EventPayload =
| PlanStepUpdatePayload
| IndexingUpdatePayload
| TaskLiveUpdatePayload
| QueueStatsUpdatePayload;
| QueueStatsUpdatePayload
| GoalSummaryUpdatePayload;
5 changes: 5 additions & 0 deletions packages/shared/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ export {
INDEXING_UPDATE,
TASK_LIVE_UPDATE,
QUEUE_STATS_UPDATE,
GOAL_SUMMARY_UPDATE,
REDIS_CHANNELS,
type TaskUpdatePayload,
type DraftUpdatePayload,
Expand All @@ -35,6 +36,7 @@ export {
type IndexingUpdatePayload,
type TaskLiveUpdatePayload,
type QueueStatsUpdatePayload,
type GoalSummaryUpdatePayload,
type ConversationEvent,
type TodoItem,
type TokenUsageInfo,
Expand Down Expand Up @@ -71,6 +73,9 @@ export {
} from './instanceAuthorization.js';

export {
getGoalCapableModels,
isGoalCapableCatalogAgent,
type GoalCapableCatalogAgent,
type InstanceCatalogAgent,
type InstanceCatalogRepository,
type InstanceCatalogResponse,
Expand Down
21 changes: 21 additions & 0 deletions packages/shared/src/instanceCatalog.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,30 @@ export interface InstanceCatalogAgent {
/** Always true: the operational catalog omits disabled entries. */
enabled: boolean;
supportedModels: string[];
/** Goal creation requires this explicit agent opt-in. */
goalCapable: boolean;
/** Supported models explicitly opted into goal execution. */
goalCapableModels: string[];
defaultModel?: string;
}

export type GoalCapableCatalogAgent = InstanceCatalogAgent & { goalCapable: true };

export function isGoalCapableCatalogAgent(
agent: InstanceCatalogAgent
): agent is GoalCapableCatalogAgent {
return agent.enabled && agent.goalCapable === true;
}

export function getGoalCapableModels(agent: InstanceCatalogAgent): string[] {
if (!isGoalCapableCatalogAgent(agent)) return [];
// Fail closed for stale/malformed catalog producers even though the V1 type
// makes the allowlist required.
if (!Array.isArray(agent.goalCapableModels)) return [];
const allowed = new Set(agent.goalCapableModels);
return agent.supportedModels.filter(model => allowed.has(model));
}

export interface InstanceCatalogRepository {
name: string;
/** Always true: the operational catalog omits disabled entries. */
Expand Down
184 changes: 8 additions & 176 deletions propr-ui/src/App.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
import React, { lazy, Suspense, useCallback, useEffect, useRef, useState } from 'react'
import { BrowserRouter as Router, Routes, Route, Link, useLocation, useNavigate } from 'react-router-dom'
import Layout from './components/Layout'
import React, { useCallback, useEffect, useRef, useState } from 'react'
import { BrowserRouter as Router } from 'react-router-dom'
import { ToastProvider } from './components/ui/Toast'
import { SocketProvider } from './contexts/SocketProvider'
import { useDemoMode } from './contexts/DemoModeContext'
Expand All @@ -13,28 +12,16 @@ import {
hostedUiConnectionIssue,
isHostedOAuthCompletionRoute,
isHostedUiOrigin,
pathWithActiveHostedTunnelFlow,
} from './config/runtimeConfig'
import { AuthProvider, useCurrentUser, userHasPermission } from './contexts/AuthContext'
import type { CurrentUser, InstancePermission } from './api/proprTypes'
import RouteChunkErrorBoundary from './components/RouteChunkErrorBoundary'
import { AuthProvider } from './contexts/AuthContext'
import type { CurrentUser } from './api/proprTypes'
import { ConnectAccountProvider } from './contexts/ConnectAccountContext'
import { BrowserPushProvider } from './hooks/useBrowserPush'
import { NotificationCenterProvider } from './contexts/NotificationCenterContext'
import AppRoutes from './AppRoutes'
import { HostedFlowRouteSync } from './AppRouteUtilities'

const AiAgentsPage = lazy(() => import('./pages/AiAgentsPage'))
const AccessManagementPage = lazy(() => import('./pages/AccessManagementPage'))
const Dashboard = lazy(() => import('./components/Dashboard'))
const LlmLogsPage = lazy(() => import('./pages/LlmLogsPage'))
const InboxPage = lazy(() => import('./pages/InboxPage'))
const LoginPage = lazy(() => import('./pages/LoginPage'))
const PlansPage = lazy(() => import('./pages/PlansPage'))
const PlanStudioPage = lazy(() => import('./pages/PlanStudioPage'))
const RepositoriesPage = lazy(() => import('./pages/RepositoriesPage'))
const RevertPage = lazy(() => import('./pages/RevertPage'))
const SettingsPage = lazy(() => import('./pages/SettingsPage'))
const SummaryBrowserPage = lazy(() => import('./pages/SummaryBrowserPage'))
const TasksPage = lazy(() => import('./pages/TasksPage'))
export { HostedFlowRouteSync, NotFoundRouteContent } from './AppRouteUtilities'

type CompatibilityState =
| { status: 'checking' }
Expand Down Expand Up @@ -99,45 +86,6 @@ const HostedOAuthCompletion: React.FC = () => (
</div>
);

const PermissionRequired: React.FC<{
permission: InstancePermission;
children: React.ReactNode;
}> = ({ permission, children }) => {
const user = useCurrentUser();
if (userHasPermission(user, permission)) return children;
return (
<div className="mx-auto max-w-2xl py-20 text-center">
<h1 className="text-2xl font-semibold text-gray-900">Administrator access required</h1>
<p className="mt-3 text-sm text-gray-600">
Your instance role does not allow you to manage this installation.
</p>
</div>
);
};

export const HostedFlowRouteSync: React.FC<{ hostname?: string }> = ({ hostname }) => {
const location = useLocation();
const navigate = useNavigate();

useEffect(() => {
const currentPath = `${location.pathname}${location.search}${location.hash}`;
const nextPath = pathWithActiveHostedTunnelFlow(currentPath, hostname);
if (nextPath !== currentPath) navigate(nextPath, { replace: true, state: location.state });
}, [hostname, location, navigate]);

return null;
};

export const NotFoundRouteContent: React.FC<{ hostname?: string }> = ({ hostname }) => (
<div className="text-center py-20">
<h2 className="text-xl font-semibold text-gray-700 mb-2">Page not found</h2>
<p className="text-gray-500 mb-4">This page does not exist or has moved.</p>
<Link to={pathWithActiveHostedTunnelFlow('/', hostname)} className="text-primary-600 hover:text-primary-700 underline">
Back to dashboard
</Link>
</div>
);

const AppContent: React.FC = () => {
const { isDemoMode, isLoading: isDemoModeLoading } = useDemoMode();
// Auth check state - start loading unless already on login page
Expand Down Expand Up @@ -231,123 +179,7 @@ const AppContent: React.FC = () => {
<Router>
<HostedFlowRouteSync />
<ConnectAccountProvider disabled={isDemoMode || currentUser === null}>
<RouteChunkErrorBoundary>
<Suspense fallback={<LoadingSpinner />}>
<Routes>
<Route path="/login" element={<LoginPage />} />
<Route path="/revert" element={<RevertPage />} />
<Route
path="/"
element={
<Layout>
<Dashboard />
</Layout>
}
/>
<Route path="/inbox" element={<Layout><InboxPage /></Layout>} />
<Route
path="/repositories"
element={
<Layout>
<RepositoriesPage />
</Layout>
}
/>
<Route
path="/tasks"
element={
<Layout>
<TasksPage />
</Layout>
}
/>
<Route
path="/tasks/:taskId"
element={
<Layout>
<TasksPage />
</Layout>
}
/>
<Route
path="/studio/new"
element={
<Layout>
<PlanStudioPage isNew />
</Layout>
}
/>
<Route
path="/studio/:draftId"
element={
<Layout>
<PlanStudioPage />
</Layout>
}
/>
<Route
path="/plans"
element={
<Layout>
<PlansPage />
</Layout>
}
/>
<Route
path="/ai-agents"
element={
<Layout>
<PermissionRequired permission="instance.manage_agents">
<AiAgentsPage />
</PermissionRequired>
</Layout>
}
/>
<Route
path="/settings"
element={
<Layout>
<SettingsPage />
</Layout>
}
/>
<Route
path="/admin/members"
element={
<Layout>
<PermissionRequired permission="instance.manage_members">
<AccessManagementPage />
</PermissionRequired>
</Layout>
}
/>
<Route
path="/summaries/:owner/:repo"
element={
<Layout>
<SummaryBrowserPage />
</Layout>
}
/>
<Route
path="/llm-logs"
element={
<Layout>
<LlmLogsPage />
</Layout>
}
/>
<Route
path="*"
element={
<Layout>
<NotFoundRouteContent />
</Layout>
}
/>
</Routes>
</Suspense>
</RouteChunkErrorBoundary>
<AppRoutes />
</ConnectAccountProvider>
</Router>
</NotificationCenterProvider>
Expand Down
26 changes: 26 additions & 0 deletions propr-ui/src/AppRouteUtilities.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import { useEffect } from 'react';
import { Link, useLocation, useNavigate } from 'react-router-dom';
import { pathWithActiveHostedTunnelFlow } from './config/runtimeConfig';

export const HostedFlowRouteSync = ({ hostname }: { hostname?: string }) => {
const location = useLocation();
const navigate = useNavigate();

useEffect(() => {
const currentPath = `${location.pathname}${location.search}${location.hash}`;
const nextPath = pathWithActiveHostedTunnelFlow(currentPath, hostname);
if (nextPath !== currentPath) navigate(nextPath, { replace: true, state: location.state });
}, [hostname, location, navigate]);

return null;
};

export const NotFoundRouteContent = ({ hostname }: { hostname?: string }) => (
<div className="py-20 text-center">
<h2 className="mb-2 text-xl font-semibold text-gray-700">Page not found</h2>
<p className="mb-4 text-gray-500">This page does not exist or has moved.</p>
<Link to={pathWithActiveHostedTunnelFlow('/', hostname)} className="text-primary-600 underline hover:text-primary-700">
Back to dashboard
</Link>
</div>
);
22 changes: 22 additions & 0 deletions propr-ui/src/AppRoutes.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import { render, screen } from '@testing-library/react';
import { MemoryRouter } from 'react-router-dom';
import { afterEach, describe, expect, it, vi } from 'vitest';
import AppRoutes from './AppRoutes';

vi.mock('./components/Layout', () => ({ default: ({ children }: { children: React.ReactNode }) => <main data-testid="layout">{children}</main> }));
vi.mock('./components/RouteChunkErrorBoundary', () => ({ default: ({ children }: { children: React.ReactNode }) => <>{children}</> }));
vi.mock('./pages/GoalsPage', () => ({ default: () => <h1>Goals route</h1> }));
vi.mock('./pages/GoalCreatePage', () => ({ default: () => <h1>Create goal route</h1> }));

describe('AppRoutes goals navigation', () => {
afterEach(() => vi.clearAllMocks());

it.each([
['/goals', 'Goals route'],
['/goals/new', 'Create goal route'],
])('renders %s inside the application layout', async (path, heading) => {
render(<MemoryRouter initialEntries={[path]}><AppRoutes /></MemoryRouter>);
expect(await screen.findByRole('heading', { name: heading })).toBeInTheDocument();
expect(screen.getByTestId('layout')).toContainElement(screen.getByRole('heading', { name: heading }));
});
});
Loading
Loading