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
2 changes: 1 addition & 1 deletion application/single_app/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -101,7 +101,7 @@
EXECUTOR_TYPE = 'thread'
EXECUTOR_MAX_WORKERS = 30
SESSION_TYPE = 'filesystem'
VERSION = "0.261.311"
VERSION = "0.261.313"
IS_DEVELOPMENT = is_development_env_enabled()

# Opt-out for deployments where App Service Easy Auth is active but the platform
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
// SharedWorkspaceOverview.tsx

import { useEffect, useMemo, useState } from 'react';
import { WorkspaceOverview, type WorkspaceOverviewSection } from './WorkspaceOverview';
import {
createSharedWorkspaceCountLoaders, loadWorkspaceCounts,
type SharedWorkspaceContext, type WorkspaceCountResult,
} from '../../lib/sharedWorkspaceCounts';
import type { ResolvedWorkspaceSection } from '../../lib/workspaceSections';
import type { WorkspaceSectionGroup } from '../../lib/types';

export function SharedWorkspaceOverview({
context, resolved, basePath, description, groupBlurbs, suspended = false,
}: {
context: SharedWorkspaceContext;
resolved: ResolvedWorkspaceSection<WorkspaceOverviewSection>[];
basePath: string;
description: string;
groupBlurbs?: Partial<Record<WorkspaceSectionGroup, string>>;
suspended?: boolean;
}) {
const loaders = useMemo(() => createSharedWorkspaceCountLoaders(context), [context]);
const sectionKey = resolved.filter((entry) => entry.enabled).map((entry) => entry.section.id).join(',');
const identity = useMemo(() => ({ loaders, sectionKey, suspended }), [loaders, sectionKey, suspended]);
const [state, setState] = useState<{
identity: typeof identity;
results: Record<string, WorkspaceCountResult>;
}>({ identity, results: {} });
useEffect(() => {
const controller = new AbortController();
setState({ identity, results: {} });
if (!suspended) {
void loadWorkspaceCounts(loaders, sectionKey.split(','), controller.signal, (id, result) => {
setState((current) => ({
identity,
results: { ...(current.identity === identity ? current.results : {}), [id]: result },
}));
});
}
return () => controller.abort();
}, [identity, loaders, sectionKey, suspended]);
const results = state.identity === identity ? state.results : {};
const counts: Record<string, number> = {};
const failedCounts = new Set<string>();
Object.entries(results).forEach(([id, result]) => {
if (result.failed) failedCounts.add(id);
else counts[id] = result.count;
});
return <WorkspaceOverview resolved={resolved} counts={counts} failedCounts={failedCounts}
basePath={basePath} description={description} groupBlurbs={groupBlurbs} showRelationships={false} />;
}
12 changes: 9 additions & 3 deletions application/v2_ui/src/components/workspace/WorkspaceOverview.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
// WorkspaceOverview.tsx

import { Link } from 'react-router-dom';
import { ArrowRight, Lock } from 'lucide-react';
import { ArrowRight, CircleX, Lock } from 'lucide-react';
import { GlassPanel } from '../ui/primitives';
import { SectionIntro } from './primitives';
import type { WorkspaceNavigationSection } from './WorkspaceShell';
Expand All @@ -14,12 +14,13 @@ export interface WorkspaceOverviewSection extends WorkspaceNavigationSection {
}

export function WorkspaceOverview({
resolved, basePath, description, counts = {}, showRelationships = true, groupBlurbs = {},
resolved, basePath, description, counts = {}, failedCounts, showRelationships = true, groupBlurbs = {},
}: {
resolved: ResolvedWorkspaceSection<WorkspaceOverviewSection>[];
basePath: string;
description: string;
counts?: Record<string, number | undefined>;
failedCounts?: ReadonlySet<string>;
showRelationships?: boolean;
/** A scope's own wording for a group's blurb; a group not named here keeps the shared one. */
groupBlurbs?: Partial<Record<WorkspaceSectionGroup, string>>;
Expand Down Expand Up @@ -60,7 +61,12 @@ export function WorkspaceOverview({
<div className="min-w-0 flex-1">
<p className="flex flex-wrap items-center gap-2 text-sm font-medium text-text-1">
{section.label}
{typeof count === 'number' ? <span className="rounded-full bg-surface-2 px-1.5 py-0.5 text-[11px] leading-none text-text-3">{count}</span> : null}
{typeof count === 'number' ? <span aria-label={`${section.label} count: ${count}`} className="rounded-full bg-surface-2 px-1.5 py-0.5 text-[11px] leading-none text-text-3">{count}</span> : null}
{failedCounts?.has(section.id) ? (
<span role="img" aria-label={`${section.label} count unavailable`} title={`${section.label} count unavailable`}>
<CircleX size={13} className="text-danger" aria-hidden="true" />
</span>
) : null}
{section.availabilityLabel ? <span className="text-xs font-normal text-text-3">{section.availabilityLabel}</span> : null}
</p>
<p className="mt-0.5 text-xs text-text-3">{section.blurb}</p>
Expand Down
12 changes: 12 additions & 0 deletions application/v2_ui/src/lib/agentDelegation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,18 @@ export async function fetchDelegationActions(scope: DelegationScope, signal?: Ab
return actions.filter((action) => action.type === 'agent');
}

export async function fetchDelegationActionCount(scope: DelegationScope, signal?: AbortSignal): Promise<number> {
const response = await api.get<unknown>(delegationResourceUrl(scope, 'plugins'), signal);
const actions = Array.isArray(response) ? response
: response && typeof response === 'object' && 'actions' in response ? response.actions : undefined;
if (!Array.isArray(actions) || !actions.every((action) =>
action && typeof action === 'object' && typeof action.id === 'string'
&& action.id && typeof action.type === 'string')) {
throw new Error('The workspace returned an invalid Call agent action list.');
}
return actions.filter((action) => action.type === 'agent').length;
}

export async function fetchDelegationAgents(scope: DelegationScope, signal?: AbortSignal) {
const response = await api.get<WorkspaceAgent[] | { agents: WorkspaceAgent[] }>(
delegationResourceUrl(scope, 'agents'), signal,
Expand Down
2 changes: 1 addition & 1 deletion application/v2_ui/src/lib/identityWorkbench.ts
Original file line number Diff line number Diff line change
Expand Up @@ -180,7 +180,7 @@ function identitiesFromResponse(value: unknown): WorkspaceIdentity[] {
if (isRecord(value) && Array.isArray(value.identities)) {
return value.identities as WorkspaceIdentity[];
}
return [];
throw new Error('The workspace returned an invalid identity list. Refresh and try again.');
}

/** The read, create and update routes wrap the record under `identity`. */
Expand Down
20 changes: 20 additions & 0 deletions application/v2_ui/src/lib/promptWorkbench.ts
Original file line number Diff line number Diff line change
Expand Up @@ -250,6 +250,26 @@ function publicPromptsUrl(workspaceId: string, promptId?: string, params?: URLSe
return query ? `${path}?${query}` : path;
}

export async function fetchSharedPromptCount(
scope: Exclude<PromptScope, { kind: 'personal' }>, signal?: AbortSignal,
): Promise<number> {
const params = new URLSearchParams({ page: '1', page_size: '1' });
const url = scope.kind === 'group'
? groupPromptsUrl(scope.id, undefined, params)
: publicPromptsUrl(scope.id, undefined, params);
const response = await api.get<unknown>(url, signal);
if (!isRecord(response) || !Array.isArray(response.prompts)
|| typeof response.total_count !== 'number' || !Number.isSafeInteger(response.total_count)
|| response.total_count < response.prompts.length) {
throw new Error('The workspace returned an invalid prompt count.');
}
response.prompts.forEach((prompt) => {
if (scope.kind === 'group') assertGroupPromptScope(prompt, scope.id);
else assertPublicPromptScope(prompt, scope.id);
});
return response.total_count;
}

/** Prove a returned prompt belongs to the requested public workspace, as the public reader does. */
function assertPublicPromptScope(prompt: WorkspacePrompt, workspaceId: string, id?: string): void {
if (!prompt || typeof prompt.id !== 'string' || !prompt.id
Expand Down
89 changes: 89 additions & 0 deletions application/v2_ui/src/lib/sharedWorkspaceCounts.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
// sharedWorkspaceCounts.ts

import { createGroupDocumentReader, createPublicDocumentReader, type DocumentReadAdapter } from './documentReadAdapter';
import { createGroupActionWorkbench } from './actionWorkbench';
import { createGroupAgentWorkbench } from './agentWorkbench';
import { fetchDelegationActionCount } from './agentDelegation';
import { createGroupFileSourceWorkbench, createPublicFileSourceWorkbench } from './fileSourceWorkbench';
import { createGroupIdentityWorkbench, createPublicIdentityWorkbench } from './identityWorkbench';
import { createGroupMembershipClient, createPublicMembershipClient } from './groupMembership';
import { createGroupModelConnectionsAdapter } from './modelConnections';
import { fetchSharedPromptCount } from './promptWorkbench';
import { fetchScopedWorkflowCount } from './workflowEditor';
import type { GroupWorkspaceContext, PublicWorkspaceContext } from './workspaceContext';

export type SharedWorkspaceContext = GroupWorkspaceContext | PublicWorkspaceContext;
export type WorkspaceCountLoader = (signal: AbortSignal) => Promise<number>;
export type WorkspaceCountLoaders = Partial<Record<string, WorkspaceCountLoader>>;
export type WorkspaceCountResult = { count: number; failed: false } | { failed: true };

export function validWorkspaceCount(value: unknown): number {
if (typeof value !== 'number' || !Number.isSafeInteger(value) || value < 0) {
throw new Error('The workspace returned an invalid count.');
}
return value;
}

function documentCountLoaders(reader: DocumentReadAdapter): WorkspaceCountLoaders {
return {
documents: async (signal) => reader.queries.facets
? validWorkspaceCount((await reader.facets(signal)).total)
: validWorkspaceCount((await reader.list({ page: 1, pageSize: 1 }, signal)).total_count),
tags: async (signal) => (await reader.tags(signal)).tags?.length ?? 0,
};
}

function isGroupContext(context: SharedWorkspaceContext): context is GroupWorkspaceContext {
return context.scope.kind === 'group';
}

export function createSharedWorkspaceCountLoaders(context: SharedWorkspaceContext): WorkspaceCountLoaders {
const { id } = context.scope;
const name = context.workspace.name;
const membersQuery = { page: 1, pageSize: 1, search: '', role: null };
if (!isGroupContext(context)) {
const scope = { kind: 'public' as const, id, name };
return {
...documentCountLoaders(createPublicDocumentReader(id, name, context.document_queries)),
prompts: (signal) => fetchSharedPromptCount(scope, signal),
sync: async (signal) => (await createPublicFileSourceWorkbench(scope, context.file_source_management).list(signal)).length,
identities: async (signal) => (await createPublicIdentityWorkbench(scope, context.identity_management).list(signal)).length,
members: context.role === 'User' ? undefined
: async (signal) => (await createPublicMembershipClient(id).list(membersQuery, signal)).totalCount,
};
}
const scope = { kind: 'group' as const, id, name };
return {
...documentCountLoaders(createGroupDocumentReader(id, name, context.document_queries)),
prompts: (signal) => fetchSharedPromptCount(scope, signal),
sync: async (signal) => (await createGroupFileSourceWorkbench(scope, context.file_source_management).list(signal)).length,
identities: async (signal) => (await createGroupIdentityWorkbench(scope, context.identity_management).list(signal)).length,
agents: async (signal) => (await createGroupAgentWorkbench(scope, context.agent_management, null).listAgents(signal)).length,
actions: context.sections.actions.enabled
? async (signal) => (await createGroupActionWorkbench(scope, context.action_management).listActions(signal)).length
: (signal) => fetchDelegationActionCount({ type: 'group', groupId: id }, signal),
workflows: (signal) => fetchScopedWorkflowCount({ type: 'group', groupId: id }, signal),
endpoints: async (signal) => (await createGroupModelConnectionsAdapter(scope, context.endpoint_management).list(signal)).endpoints.length,
members: async (signal) => (await createGroupMembershipClient(id).list(membersQuery, signal)).totalCount,
};
}

/** Independent results are published as they arrive; one failed service never hides the others. */
export async function loadWorkspaceCounts(
loaders: WorkspaceCountLoaders,
sectionIds: readonly string[],
signal: AbortSignal,
onResult: (id: string, result: WorkspaceCountResult) => void,
): Promise<void> {
await Promise.all(sectionIds.map(async (id) => {
const load = loaders[id];
if (!load || signal.aborted) return;
let result: WorkspaceCountResult;
try {
result = { count: validWorkspaceCount(await load(signal)), failed: false };
} catch {
result = { failed: true };
}
if (!signal.aborted) onResult(id, result);
}));
}
10 changes: 10 additions & 0 deletions application/v2_ui/src/lib/workflowEditor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2534,6 +2534,16 @@ export async function fetchScopedWorkflows(
.map((workflow) => normalizeWorkflowDefinition(workflow, scope));
}

export async function fetchScopedWorkflowCount(scope: WorkflowScope, signal?: AbortSignal): Promise<number> {
const response = await api.get<unknown>(workflowUrl(scope), signal);
const workflows = Array.isArray(response) ? response : isRecord(response) ? response.workflows : undefined;
if (!Array.isArray(workflows) || !workflows.every((workflow) =>
isRecord(workflow) && typeof workflow.id === 'string' && workflow.id)) {
throw new Error('The workspace returned an invalid workflow list.');
}
return workflows.length;
}

export function saveWorkflowDefinition(
scope: WorkflowScope,
draft: WorkflowDefinition,
Expand Down
4 changes: 2 additions & 2 deletions application/v2_ui/src/pages/GroupWorkspacePage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import { PageHeader } from '../components/layout/PageHeader';
import { EmptyState, GlassButton, Skeleton } from '../components/ui/primitives';
import { GroupWorkspacePicker } from '../components/workspace/GroupWorkspacePicker';
import { WorkspaceLeavePrompt } from '../components/workspace/WorkspaceEditorFrame';
import { WorkspaceOverview } from '../components/workspace/WorkspaceOverview';
import { SharedWorkspaceOverview } from '../components/workspace/SharedWorkspaceOverview';
import { WorkspaceShell } from '../components/workspace/WorkspaceShell';
import { Pill, SectionIntro } from '../components/workspace/primitives';
import {
Expand Down Expand Up @@ -452,7 +452,7 @@ export function GroupWorkspacePage() {
<div><dt className="text-xs text-text-3">About this group</dt><dd className="break-words">{context.workspace.description || 'No description provided.'}</dd></div>
<div><dt className="text-xs text-text-3">Owner</dt><dd className="break-words">{context.workspace.owner.display_name || 'Owner information unavailable'}{context.workspace.owner.email ? ` · ${context.workspace.owner.email}` : ''}</dd></div>
</dl>
<WorkspaceOverview basePath={basePath} resolved={resolved} showRelationships={false}
<SharedWorkspaceOverview context={context} basePath={basePath} resolved={resolved} suspended={accessUnconfirmed}
description="Shared documents, prompts and automation for this group. A locked section shows why it's unavailable to you." />
</>
) : !selected ? <EmptyState icon={<LayoutGrid size={28} />} title="Section not found" description="Choose a section from this workspace's navigation." />
Expand Down
4 changes: 2 additions & 2 deletions application/v2_ui/src/pages/PublicWorkspacePage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ import { PageHeader } from '../components/layout/PageHeader';
import { EmptyState, GlassButton, GlassPanel, Skeleton } from '../components/ui/primitives';
import { PublicWorkspacePicker } from '../components/workspace/PublicWorkspacePicker';
import { WorkspaceLeavePrompt } from '../components/workspace/WorkspaceEditorFrame';
import { WorkspaceOverview } from '../components/workspace/WorkspaceOverview';
import { SharedWorkspaceOverview } from '../components/workspace/SharedWorkspaceOverview';
import { WorkspaceShell } from '../components/workspace/WorkspaceShell';
import { Pill, SectionIntro } from '../components/workspace/primitives';
import {
Expand Down Expand Up @@ -307,7 +307,7 @@ export function PublicWorkspacePage() {
<div><dt className="text-xs text-text-3">About this workspace</dt><dd className="break-words">{context.workspace.description || 'No description provided.'}</dd></div>
<div><dt className="text-xs text-text-3">Owner</dt><dd className="break-words">{context.workspace.owner.display_name || 'Owner information unavailable'}{context.workspace.owner.email ? ` · ${context.workspace.owner.email}` : ''}</dd></div>
</dl>
<WorkspaceOverview basePath={basePath} resolved={resolved} showRelationships={false}
<SharedWorkspaceOverview context={context} basePath={basePath} resolved={resolved} suspended={state.refreshing}
groupBlurbs={PUBLIC_GROUP_BLURBS}
description={`Published documents and prompts for this ${labels.lower_singular}, and how it's connected and run. A locked section shows why it's unavailable to you.`} />
</>
Expand Down
Loading
Loading