Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
dcc56b1
feat: add Integration._parentId and ResourceTypeEnum.AGENT for agent …
ChmaraX Apr 30, 2026
ec59bc5
feat(agents): add SyncAgentToEnvironment usecase
ChmaraX Apr 30, 2026
af62d98
feat(agents): add AgentRepositoryAdapter and AgentComparatorAdapter
ChmaraX Apr 30, 2026
17a3026
feat(agents): add AgentSyncStrategy, operations, and sync/delete adap…
ChmaraX Apr 30, 2026
ebce61c
feat(agents): wire AgentSyncStrategy into publish and diff pipelines
ChmaraX Apr 30, 2026
4992b9c
feat(agents): forbid adding/removing integrations in production envir…
ChmaraX Apr 30, 2026
32137bb
feat(agents): add E2E tests for agent promotion and prod guards
ChmaraX Apr 30, 2026
da4b1a5
feat(agents): gate agent sync strategy behind IS_CONVERSATIONAL_AGENT…
ChmaraX Apr 30, 2026
27e3faa
fix: remove only
ChmaraX Apr 30, 2026
d2f19a8
feat(dashboard): wire agent resources into the publish modal
ChmaraX Apr 30, 2026
dfd779d
fix(api-service): address code review feedback on agent promotion
ChmaraX Apr 30, 2026
7fd80c0
fix(api-service): address code review feedback on agent promotion
ChmaraX Apr 30, 2026
9ffb1ea
fix(api-service): address second round of code review feedback
ChmaraX Apr 30, 2026
067c964
fix(api-service): update unit test stubs for integrationRepo.findOne …
ChmaraX Apr 30, 2026
751ed7f
feat(dashboard): agent production environment UI and publish flow
ChmaraX Apr 30, 2026
bd5c55b
Merge remote-tracking branch 'origin/feat/agents-environment-promotio…
ChmaraX Apr 30, 2026
18de3f8
Merge branch 'next' into feat/agents-environment-promotion-dashboard
ChmaraX Apr 30, 2026
e76e064
fix(dashboard): use undefined instead of null for optional bridgeUrl
ChmaraX Apr 30, 2026
5d0019e
fix(dashboard): address code review feedback
ChmaraX Apr 30, 2026
8416a1e
fix(dashboard): allow clearing bridge URL and encode identifier in nav
ChmaraX Apr 30, 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
Original file line number Diff line number Diff line change
Expand Up @@ -29,8 +29,25 @@ export class UpdateAgent {
throw new BadRequestException('At least one field must be provided.');
}

if (command.devBridgeActive === true || (command.devBridgeUrl !== undefined && command.devBridgeUrl !== null)) {
await this.assertNotProductionEnvironment(command.environmentId, command.organizationId);
const hasReadOnlyFields =
command.name !== undefined ||
command.description !== undefined ||
hasBehaviorFields;

if (hasReadOnlyFields) {
await this.assertNotProduction(
command.environmentId,
command.organizationId,
'Only the active status and bridge URL can be modified in production environments.'
);
}

if (command.devBridgeActive !== undefined || command.devBridgeUrl !== undefined) {
await this.assertNotProduction(
command.environmentId,
command.organizationId,
'Dev bridge settings cannot be modified in production environments.'
);
}

// The bridge executor `fetch()`s these URLs from inside the API process on every
Expand Down Expand Up @@ -113,14 +130,14 @@ export class UpdateAgent {
return toAgentResponse(updated);
}

private async assertNotProductionEnvironment(environmentId: string, organizationId: string): Promise<void> {
private async assertNotProduction(environmentId: string, organizationId: string, message: string): Promise<void> {
const environment = await this.environmentRepository.findOne(
{ _id: environmentId, _organizationId: organizationId },
['type', 'name']
);

if (environment?.type === EnvironmentTypeEnum.PROD) {
throw new ForbiddenException('Dev bridge cannot be activated on production environments.');
throw new ForbiddenException(message);
}
}

Expand Down
2 changes: 1 addition & 1 deletion apps/dashboard/src/api/environments.ts
Original file line number Diff line number Diff line change
Expand Up @@ -84,7 +84,7 @@ export interface IEnvironmentPublishResponse {
}

export type ResourceToPublish = {
resourceType: 'workflow' | 'layout' | 'localization_group' | 'step';
resourceType: 'workflow' | 'layout' | 'localization_group' | 'step' | 'agent';
resourceId: string;
};

Expand Down
3 changes: 3 additions & 0 deletions apps/dashboard/src/components/agents/agent-details-header.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {
DropdownMenuTrigger,
} from '@/components/primitives/dropdown-menu';
import { Skeleton } from '@/components/primitives/skeleton';
import { useEnvironment } from '@/context/environment/hooks';
import { useHasPermission } from '@/hooks/use-has-permission';

type AgentDetailsHeaderProps = {
Expand All @@ -20,6 +21,7 @@ type AgentDetailsHeaderProps = {

export function AgentDetailsHeader({ agent, isLoading, onRequestDelete }: AgentDetailsHeaderProps) {
const has = useHasPermission();
const { readOnly } = useEnvironment();
const canWrite = has({ permission: PermissionsEnum.AGENT_WRITE });

if (isLoading || !agent) {
Expand Down Expand Up @@ -74,6 +76,7 @@ export function AgentDetailsHeader({ agent, isLoading, onRequestDelete }: AgentD
<DropdownMenuContent align="end">
<DropdownMenuItem
className="text-destructive cursor-pointer"
disabled={readOnly}
onClick={() => {
setTimeout(() => onRequestDelete(agent), 0);
}}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,15 @@ import {
} from '@/components/primitives/dropdown-menu';
import { API_HOSTNAME } from '@/config';

export type AgentIntegrationGuideHeaderProps = {
providerId: string;
providerDisplayName: string;
integrationLink: AgentIntegrationLink;
canRemoveIntegration: boolean;
onRequestRemoveIntegration?: () => void;
isRemovingIntegration?: boolean;
};

type AgentIntegrationGuideLayoutProps = {
providerDisplayName: string;
providerId: string;
Expand All @@ -38,86 +47,67 @@ function buildWebhookUrl(agentId: string, integrationIdentifier: string): string
return `${baseUrl}/v1/agents/${agentId}/webhook/${integrationIdentifier}`;
}

export function AgentIntegrationGuideLayout({
providerDisplayName,
export function AgentIntegrationGuideHeader({
providerId,
onBack,
children,
embedded = false,
agent,
providerDisplayName,
integrationLink,
canRemoveIntegration,
onRequestRemoveIntegration,
isRemovingIntegration = false,
}: AgentIntegrationGuideLayoutProps) {
const webhookUrlId = useId();
const isActive = integrationLink?.integration.active ?? false;
const integrationIdentifier = integrationLink?.integration.identifier;
const createdAt = integrationLink?.createdAt;
const webhookUrl = buildWebhookUrl(agent._id, integrationIdentifier ?? 'YOUR_INTEGRATION_IDENTIFIER');
}: AgentIntegrationGuideHeaderProps) {
const isConnected = Boolean(integrationLink.connectedAt);
const integrationIdentifier = integrationLink.integration.identifier;
const createdAt = integrationLink.createdAt;

return (
<div className="flex w-full flex-col gap-6">
{!embedded && (
<CompactButton
type="button"
size="lg"
variant="ghost"
className="w-fit"
icon={RiArrowLeftSLine}
onClick={onBack}
>
Back to integrations
</CompactButton>
)}

<header className="flex items-start justify-between">
<div className="flex min-w-0 flex-col gap-2">
<div className="flex items-center gap-2">
<div className="flex items-center gap-1.5">
<ProviderIcon
providerId={providerId}
providerDisplayName={providerDisplayName}
className="size-4 shrink-0"
/>
<span className="text-text-strong text-label-sm font-medium leading-5">{providerDisplayName}</span>
</div>
{isActive ? (
<span className="bg-success-lighter flex items-center gap-1 rounded-md px-1 py-0.5">
<span className="flex size-4 items-center justify-center rounded-full bg-success-lighter">
<span className="bg-success-base size-1.5 rounded-full" />
</span>
<span className="text-success-base text-label-xs font-medium leading-4">Active</span>
</span>
) : (
<span className="bg-error-lighter flex items-center gap-1 rounded-md px-1 py-0.5">
<span className="bg-error-lighter flex size-4 items-center justify-center rounded-full">
<span className="bg-error-base size-1.5 rounded-full" />
</span>
<span className="text-error-base text-label-xs font-medium leading-4">Action needed</span>
</span>
)}
<header className="flex items-start justify-between">
<div className="flex min-w-0 flex-col gap-2">
<div className="flex items-center gap-2">
<div className="flex items-center gap-1.5">
<ProviderIcon
providerId={providerId}
providerDisplayName={providerDisplayName}
className="size-4 shrink-0"
/>
<span className="text-text-strong text-label-sm font-medium leading-5">{providerDisplayName}</span>
</div>

{integrationIdentifier ? (
<div className="flex items-center gap-1.5">
<span className="text-text-sub font-mono text-[12px] leading-4 tracking-tight">
{integrationIdentifier}
{isConnected ? (
<span className="bg-success-lighter flex items-center gap-1 rounded-md px-1 py-0.5">
<span className="flex size-4 items-center justify-center rounded-full bg-success-lighter">
<span className="bg-success-base size-1.5 rounded-full" />
</span>
{createdAt ? (
<>
<span className="bg-text-soft size-0.5 shrink-0 rounded-full" />
<span className="text-[12px] leading-4">
<span className="text-text-soft">Created </span>
<span className="text-text-sub font-medium">{formatCreatedDate(createdAt)}</span>
</span>
</>
) : null}
</div>
) : null}
<span className="text-success-base text-label-xs font-medium leading-4">Connected</span>
</span>
) : (
<span className="bg-error-lighter flex items-center gap-1 rounded-md px-1 py-0.5">
<span className="bg-error-lighter flex size-4 items-center justify-center rounded-full">
<span className="bg-error-base size-1.5 rounded-full" />
</span>
<span className="text-error-base text-label-xs font-medium leading-4">Action needed</span>
</span>
)}
</div>

{integrationLink && canRemoveIntegration && onRequestRemoveIntegration ? (
{integrationIdentifier ? (
<div className="flex items-center gap-1.5">
<span className="text-text-sub font-mono text-[12px] leading-4 tracking-tight">
{integrationIdentifier}
</span>
{createdAt ? (
<>
<span className="bg-text-soft size-0.5 shrink-0 rounded-full" />
<span className="text-[12px] leading-4">
<span className="text-text-soft">Created </span>
<span className="text-text-sub font-medium">{formatCreatedDate(createdAt)}</span>
</span>
</>
) : null}
</div>
) : null}
</div>

<div className="flex items-center gap-2">
{onRequestRemoveIntegration ? (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
Expand All @@ -135,15 +125,61 @@ export function AgentIntegrationGuideLayout({
<DropdownMenuContent align="end">
<DropdownMenuItem
className="text-destructive cursor-pointer"
disabled={isRemovingIntegration}
disabled={!canRemoveIntegration || isRemovingIntegration}
onClick={onRequestRemoveIntegration}
>
Remove integration
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
) : null}
</header>
</div>
</header>
);
}

export function AgentIntegrationGuideLayout({
providerDisplayName,
providerId,
onBack,
children,
embedded = false,
agent,
integrationLink,
canRemoveIntegration,
onRequestRemoveIntegration,
isRemovingIntegration = false,
}: AgentIntegrationGuideLayoutProps) {
const webhookUrlId = useId();
const integrationIdentifier = integrationLink?.integration.identifier;
const webhookUrl = buildWebhookUrl(agent._id, integrationIdentifier ?? 'YOUR_INTEGRATION_IDENTIFIER');

return (
<div className="flex w-full flex-col gap-6">
{!embedded && (
<CompactButton
type="button"
size="lg"
variant="ghost"
className="w-fit"
icon={RiArrowLeftSLine}
onClick={onBack}
>
Back to integrations
</CompactButton>
)}

{integrationLink ? (
<AgentIntegrationGuideHeader
providerId={providerId}
providerDisplayName={providerDisplayName}
integrationLink={integrationLink}
canRemoveIntegration={canRemoveIntegration}
onRequestRemoveIntegration={onRequestRemoveIntegration}
isRemovingIntegration={isRemovingIntegration}

/>
) : null}

<section className="flex flex-col gap-4">
<h3 className="text-text-sub text-[11px] font-medium uppercase leading-4 tracking-wider">Agent metadata</h3>
Expand Down
Loading
Loading