feat(core): Add endpoints and UI to manage agent version history (no-changelog) (#30954)

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-authored-by: yehorkardash <yehor.kardash@n8n.io>
This commit is contained in:
Eugene 2026-06-01 09:28:01 +02:00 committed by GitHub
parent 1a9a69a9e6
commit be3241dc22
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
22 changed files with 1717 additions and 9 deletions

View File

@ -60,6 +60,10 @@ export class PublishAgentDto extends Z.class({
versionId: z.string().min(1).optional(),
}) {}
export class RevertAgentToVersionDto extends Z.class({
versionId: z.string().min(1),
}) {}
export class CreateSlackAgentAppDto extends Z.class({
appConfigurationToken: z.string().min(1),
}) {}

View File

@ -121,6 +121,15 @@ export interface AgentVersionDto {
author: string;
}
export interface AgentVersionListItemDto {
versionId: string;
agentId: string;
createdAt: string;
updatedAt: string;
author: string;
isActive: boolean;
}
export interface AgentPersistedMessageContentPart {
type: 'text' | 'reasoning' | 'tool-call' | (string & {});
text?: string;

View File

@ -110,13 +110,71 @@ describe('AgentsController route access scopes', () => {
['updateSkill', 'agent:update'],
['deleteSkill', 'agent:update'],
['revertToPublished', 'agent:update'],
['revertToVersion', 'agent:update'],
['createSlackApp', 'agent:update'],
['getSlackAppManifest', 'agent:read'],
['listVersions', 'agent:read'],
])('%s uses %s', (handlerName, scope) => {
expect(metadata.routes.get(handlerName)?.accessScope?.scope).toBe(scope);
});
});
describe('AgentsController publish history', () => {
it('lists publish history with pagination forwarded from the query', async () => {
const { controller, agentsService } = makeController();
agentsService.listPublishHistory.mockResolvedValue([
{
versionId: 'v2',
agentId: 'agent-1',
createdAt: '2026-01-02T00:00:00.000Z',
updatedAt: '2026-01-02T00:00:00.000Z',
author: 'Ada Lovelace',
isActive: true,
},
]);
const result = await controller.listVersions(
{ params: { projectId: 'project-1', agentId: 'agent-1' } } as never,
undefined as never,
'agent-1',
{ take: 5, skip: 10 } as never,
);
expect(agentsService.listPublishHistory).toHaveBeenCalledWith('agent-1', 'project-1', 5, 10);
expect(result).toHaveLength(1);
expect(result[0].isActive).toBe(true);
});
});
describe('AgentsController revert to version', () => {
it('forwards the parsed versionId to the service and returns the agent with runnable state', async () => {
const { controller, agentsService } = makeController();
agentsService.revertToVersion.mockResolvedValue({
id: 'agent-1',
projectId: 'project-1',
} as never);
agentsService.validateAgentIsRunnable.mockResolvedValue({ missing: [] });
const result = await controller.revertToVersion(
{
params: { projectId: 'project-1' },
user: { id: 'user-1' },
} as never,
undefined as never,
'agent-1',
{ versionId: 'v1' } as never,
);
expect(agentsService.revertToVersion).toHaveBeenCalledWith('agent-1', 'project-1', 'v1');
expect(result).toEqual(
expect.objectContaining({
id: 'agent-1',
isRunnable: true,
}),
);
});
});
describe('AgentsController integration credentials', () => {
it('rejects credentials that are not usable in the agent project', async () => {
const credentialsService = mock<CredentialsService>();

View File

@ -718,8 +718,8 @@ describe('AgentsService', () => {
});
describe('with explicit versionId', () => {
it('flips activeVersionId to an existing history row without creating a new one', async () => {
const agent = makeAgent({ versionId: 'v2' });
it('flips activeVersionId to an existing history row and bumps draft versionId', async () => {
const agent = makeAgent({ versionId: 'v2', activeVersionId: 'v2' });
const existingHistory = makeAgentHistory({ versionId: 'v1' });
agentRepository.findByIdAndProjectId.mockResolvedValue(agent);
agentHistoryRepository.findByVersionAndAgentId.mockResolvedValue(existingHistory);
@ -734,9 +734,30 @@ describe('AgentsService', () => {
expect(agentHistoryRepository.saveVersion).not.toHaveBeenCalled();
expect(agent.activeVersionId).toBe('v1');
expect(agent.activeVersion).toBe(existingHistory);
// Draft versionId was v2 (a history-row PK). Bumping to a fresh
// UUID lets the next regular publish snapshot cleanly.
expect(agent.versionId).not.toBe('v2');
expect(agent.versionId).not.toBe('v1');
expect(agent.versionId).toMatch(
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/,
);
expect(mockTrx.save).toHaveBeenCalledWith(agent);
});
it('is a no-op when the requested version is already active', async () => {
const agent = makeAgent({ versionId: 'v1', activeVersionId: 'v1' });
agentRepository.findByIdAndProjectId.mockResolvedValue(agent);
const result = await service.publishAgent(agentId, projectId, testUser, 'v1');
expect(agentHistoryRepository.findByVersionAndAgentId).not.toHaveBeenCalled();
expect(agentHistoryRepository.saveVersion).not.toHaveBeenCalled();
expect(mockTrx.save).not.toHaveBeenCalled();
expect(agent.versionId).toBe('v1');
expect(agent.activeVersionId).toBe('v1');
expect(result).toBe(agent);
});
it('throws NotFoundError when the versionId does not belong to the agent', async () => {
agentRepository.findByIdAndProjectId.mockResolvedValue(makeAgent({ versionId: 'v2' }));
agentHistoryRepository.findByVersionAndAgentId.mockResolvedValue(null);
@ -1461,6 +1482,222 @@ describe('AgentsService', () => {
});
});
describe('revertToVersion', () => {
let mockTrx: { save: jest.Mock };
let mockTransaction: jest.Mock;
beforeEach(() => {
mockTrx = { save: jest.fn() };
mockTransaction = jest.fn(
async (cb: (trx: typeof mockTrx) => Promise<void>) => await cb(mockTrx),
);
Object.defineProperty(agentRepository, 'manager', {
value: { transaction: mockTransaction },
configurable: true,
});
});
it('throws NotFoundError when the agent does not exist', async () => {
agentRepository.findByIdAndProjectId.mockResolvedValue(null);
await expect(service.revertToVersion(agentId, projectId, 'v1')).rejects.toThrow(
NotFoundError,
);
expect(agentHistoryRepository.findByVersionAndAgentId).not.toHaveBeenCalled();
});
it('throws NotFoundError when the version does not exist for the agent', async () => {
agentRepository.findByIdAndProjectId.mockResolvedValue(makeAgent());
agentHistoryRepository.findByVersionAndAgentId.mockResolvedValue(null);
await expect(service.revertToVersion(agentId, projectId, 'foreign-version')).rejects.toThrow(
NotFoundError,
);
expect(agentHistoryRepository.findByVersionAndAgentId).toHaveBeenCalledWith(
'foreign-version',
agentId,
mockTrx,
);
expect(mockTrx.save).not.toHaveBeenCalled();
});
it('restores the draft fields from the targeted snapshot and leaves activeVersionId untouched', async () => {
const snapshotSchema: AgentJsonConfig = {
name: 'Old Agent',
description: 'Old description',
model: 'anthropic/claude-sonnet-4-5',
credential: 'cred-old',
instructions: 'Old instructions',
tools: [{ type: 'custom', id: 'old_tool' }],
skills: [{ type: 'skill', id: 'old_skill' }],
};
const snapshotTools = {
old_tool: {
code: 'return "old";',
descriptor: { name: 'old_tool' },
},
} as unknown as Agent['tools'];
const snapshotSkills = {
old_skill: {
name: 'Old skill',
description: 'Old skill description',
instructions: 'Old skill instructions',
},
};
const targetVersion = makeAgentHistory({
versionId: 'v1',
schema: snapshotSchema,
tools: snapshotTools,
skills: snapshotSkills,
});
const activeVersion = makeAgentHistory({ versionId: 'v2' });
const agent = makeAgent({
name: 'Current Agent',
description: 'Current description',
versionId: 'v3',
schema: {
name: 'Current Agent',
model: 'anthropic/claude-sonnet-4-5',
instructions: 'Current instructions',
},
tools: {},
skills: {},
activeVersionId: 'v2',
activeVersion,
});
agentRepository.findByIdAndProjectId.mockResolvedValue(agent);
agentHistoryRepository.findByVersionAndAgentId.mockResolvedValue(targetVersion);
const result = await service.revertToVersion(agentId, projectId, 'v1');
expect(agent.schema).toEqual(snapshotSchema);
expect(agent.schema).not.toBe(snapshotSchema);
expect(agent.tools).toEqual(snapshotTools);
expect(agent.skills).toEqual(snapshotSkills);
// Fresh UUID so the next publish snapshot doesn't collide with the
// targeted history row's PK and doesn't fast-path past the revert.
expect(agent.versionId).not.toBe('v1');
expect(agent.versionId).not.toBe('v3');
expect(agent.versionId).not.toBe(agent.activeVersionId);
expect(agent.versionId).toMatch(
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/,
);
expect(agent.name).toBe('Old Agent');
expect(agent.description).toBe('Old description');
expect(agent.activeVersionId).toBe('v2');
expect(agent.activeVersion).toBe(activeVersion);
expect(mockTrx.save).toHaveBeenCalledWith(agent);
expect(result).toBe(agent);
});
it('reverts successfully when the agent is currently unpublished', async () => {
const snapshotSchema: AgentJsonConfig = {
name: 'Restored Agent',
model: 'anthropic/claude-sonnet-4-5',
instructions: 'Restored instructions',
};
const targetVersion = makeAgentHistory({
versionId: 'v1',
schema: snapshotSchema,
tools: {},
skills: {},
});
const agent = makeAgent({ activeVersionId: null, activeVersion: null });
agentRepository.findByIdAndProjectId.mockResolvedValue(agent);
agentHistoryRepository.findByVersionAndAgentId.mockResolvedValue(targetVersion);
await service.revertToVersion(agentId, projectId, 'v1');
expect(agent.activeVersionId).toBeNull();
expect(agent.versionId).not.toBe('v1');
expect(agent.versionId).toMatch(
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/,
);
expect(agent.name).toBe('Restored Agent');
expect(mockTrx.save).toHaveBeenCalledWith(agent);
});
});
describe('listPublishHistory', () => {
it('throws NotFoundError when the agent does not exist in the project', async () => {
agentRepository.findByIdAndProjectId.mockResolvedValue(null);
await expect(service.listPublishHistory(agentId, projectId, 20, 0)).rejects.toThrow(
NotFoundError,
);
expect(agentHistoryRepository.findByAgentId).not.toHaveBeenCalled();
});
it('forwards take and skip to the repository', async () => {
agentRepository.findByIdAndProjectId.mockResolvedValue(makeAgent({ activeVersionId: null }));
agentHistoryRepository.findByAgentId.mockResolvedValue([]);
await service.listPublishHistory(agentId, projectId, 5, 10);
expect(agentHistoryRepository.findByAgentId).toHaveBeenCalledWith(agentId, 5, 10);
});
it('maps history rows to DTOs and marks the active version', async () => {
const createdAt = new Date('2026-01-01T00:00:00.000Z');
const updatedAt = new Date('2026-01-02T00:00:00.000Z');
const activeRow = {
versionId: 'v2',
agentId,
createdAt,
updatedAt,
author: 'Ada Lovelace',
} as unknown as AgentHistory;
const inactiveRow = {
versionId: 'v1',
agentId,
createdAt,
updatedAt,
author: 'Unknown',
} as unknown as AgentHistory;
agentRepository.findByIdAndProjectId.mockResolvedValue(makeAgent({ activeVersionId: 'v2' }));
agentHistoryRepository.findByAgentId.mockResolvedValue([activeRow, inactiveRow]);
const result = await service.listPublishHistory(agentId, projectId, 20, 0);
expect(result).toEqual([
{
versionId: 'v2',
agentId,
createdAt: createdAt.toISOString(),
updatedAt: updatedAt.toISOString(),
author: 'Ada Lovelace',
isActive: true,
},
{
versionId: 'v1',
agentId,
createdAt: createdAt.toISOString(),
updatedAt: updatedAt.toISOString(),
author: 'Unknown',
isActive: false,
},
]);
});
it('marks every row as inactive when the agent is unpublished', async () => {
const createdAt = new Date('2026-01-01T00:00:00.000Z');
agentRepository.findByIdAndProjectId.mockResolvedValue(makeAgent({ activeVersionId: null }));
agentHistoryRepository.findByAgentId.mockResolvedValue([
{
versionId: 'v1',
agentId,
createdAt,
updatedAt: createdAt,
author: 'Ada Lovelace',
} as unknown as AgentHistory,
]);
const result = await service.listPublishHistory(agentId, projectId, 20, 0);
expect(result[0].isActive).toBe(false);
});
});
describe('getConversationHistory', () => {
it('returns the user-visible transcript from execution history', async () => {
agentExecutionService.getThreadDetail.mockResolvedValue({

View File

@ -9,6 +9,7 @@ import {
type AgentScheduleConfig,
type AgentSkill,
type AgentSseEvent,
type AgentVersionListItemDto,
type ChatIntegrationDescriptor,
CreateSlackAgentAppDto,
type CreateSlackAgentAppResponse,
@ -16,12 +17,14 @@ import {
CreateAgentDto,
CreateAgentSkillDto,
isAgentCredentialIntegration,
PaginationDto,
UpdateAgentConfigDto,
UpdateAgentDto,
UpdateAgentScheduleDto,
UpdateAgentSkillDto,
AgentDisconnectIntegrationDto,
PublishAgentDto,
RevertAgentToVersionDto,
} from '@n8n/api-types';
import type { AuthenticatedRequest, User } from '@n8n/db';
import {
@ -33,6 +36,7 @@ import {
Post,
ProjectScope,
Put,
Query,
RestController,
} from '@n8n/decorators';
import { randomUUID } from 'crypto';
@ -128,19 +132,21 @@ export class AgentsController {
agent: Agent,
projectId: string,
user: User,
): Promise<Agent & { isRunnable: boolean }> {
): Promise<Agent & { isRunnable: boolean; hasPublishHistory: boolean }> {
const credentialProvider = new AgentsCredentialProvider(
this.credentialsService,
projectId,
user,
);
const { missing } = await this.agentsService.validateAgentIsRunnable(
agent.id,
projectId,
credentialProvider,
);
const [{ missing }, hasPublishHistory] = await Promise.all([
this.agentsService.validateAgentIsRunnable(agent.id, projectId, credentialProvider),
this.agentsService.hasPublishHistory(agent.id),
]);
return Object.assign(agent, { isRunnable: missing.length === 0 });
return Object.assign(agent, {
isRunnable: missing.length === 0,
hasPublishHistory,
});
}
@Post('/')
@ -436,6 +442,38 @@ export class AgentsController {
return await this.withRunnableState(agent, req.params.projectId, req.user);
}
@Post('/:agentId/revert-to-version')
@ProjectScope('agent:update')
async revertToVersion(
req: AuthenticatedRequest<{ projectId: string }>,
_res: Response,
@Param('agentId') agentId: string,
@Body payload: RevertAgentToVersionDto,
) {
const agent = await this.agentsService.revertToVersion(
agentId,
req.params.projectId,
payload.versionId,
);
return await this.withRunnableState(agent, req.params.projectId, req.user);
}
@Get('/:agentId/versions')
@ProjectScope('agent:read')
async listVersions(
req: AuthenticatedRequest<{ projectId: string; agentId: string }>,
_res: Response,
@Param('agentId') agentId: string,
@Query query: PaginationDto,
): Promise<AgentVersionListItemDto[]> {
return await this.agentsService.listPublishHistory(
agentId,
req.params.projectId,
query.take,
query.skip,
);
}
@Post('/:agentId/chat', { usesTemplates: true })
@ProjectScope('agent:execute')
async chat(

View File

@ -24,6 +24,7 @@ import {
type AgentJsonToolConfig,
type AgentSkill,
type AgentSkillMutationResponse,
type AgentVersionListItemDto,
type ChatIntegrationDescriptor,
AgentPersistedMessageDto,
} from '@n8n/api-types';
@ -520,6 +521,12 @@ export class AgentsService {
return agent;
}
// Idempotent fast-path for re-publishing the already-active version —
// no pointer to flip, no need to disturb the draft's versionId.
if (versionId !== undefined && versionId === agent.activeVersionId) {
return agent;
}
await this.agentRepository.manager.transaction(async (trx) => {
if (versionId) {
const existing = await this.agentHistoryRepository.findByVersionAndAgentId(
@ -532,6 +539,11 @@ export class AgentsService {
}
agent.activeVersionId = existing.versionId;
agent.activeVersion = existing;
// The previously-active versionId may already own a history row
// (e.g. the draft was in sync with the old active version). Bump
// to a fresh UUID so the next regular publish writes a new row
// instead of colliding on that PK.
agent.versionId = uuid();
} else {
// Snapshot the current draft. agent.versionId is the snapshot's PK,
// so make sure it's set before inserting.
@ -658,6 +670,82 @@ export class AgentsService {
return agent;
}
async revertToVersion(agentId: string, projectId: string, versionId: string): Promise<Agent> {
const agent = await this.agentRepository.findByIdAndProjectId(agentId, projectId);
if (!agent) {
throw new NotFoundError(`Agent "${agentId}" not found`);
}
await this.agentRepository.manager.transaction(async (trx) => {
const target = await this.agentHistoryRepository.findByVersionAndAgentId(
versionId,
agentId,
trx,
);
if (!target) {
throw new NotFoundError(`Version "${versionId}" not found`);
}
agent.schema = target.schema ? deepCopy(target.schema) : null;
agent.tools = deepCopy(target.tools ?? {});
agent.skills = deepCopy(target.skills ?? {});
// Fresh UUID so a follow-up publish writes a new history row.
// Re-using target.versionId would collide on the snapshot insert
// (target already owns that PK), and leaving the previous
// versionId in place could equal activeVersionId — that hits the
// idempotent fast-path in publishAgent and silently discards the
// revert.
agent.versionId = uuid();
if (agent.schema) {
agent.name = agent.schema.name;
agent.description = agent.schema.description ?? null;
}
await trx.save(agent);
});
this.clearRuntimes(agentId);
this.logger.debug('Reverted SDK agent to a specific version', {
agentId,
projectId,
versionId,
});
return agent;
}
/**
* Cheap existence check used by the editor to gate the version-history
* panel button. Survives unpublish, unlike `agent.activeVersionId`.
*/
async hasPublishHistory(agentId: string): Promise<boolean> {
return await this.agentHistoryRepository.existsForAgent(agentId);
}
async listPublishHistory(
agentId: string,
projectId: string,
take: number,
skip: number,
): Promise<AgentVersionListItemDto[]> {
const agent = await this.agentRepository.findByIdAndProjectId(agentId, projectId);
if (!agent) {
throw new NotFoundError(`Agent "${agentId}" not found`);
}
const versions = await this.agentHistoryRepository.findByAgentId(agentId, take, skip);
return versions.map((v) => ({
versionId: v.versionId,
agentId: v.agentId,
createdAt: v.createdAt.toISOString(),
updatedAt: v.updatedAt.toISOString(),
author: v.author,
isActive: v.versionId === agent.activeVersionId,
}));
}
async delete(agentId: string, projectId: string): Promise<boolean> {
const agent = await this.agentRepository.findByIdAndProjectId(agentId, projectId);

View File

@ -77,4 +77,33 @@ export class AgentHistoryRepository extends Repository<AgentHistory> {
const repo = trx?.getRepository(AgentHistory) ?? this;
return await repo.findOneBy({ versionId, agentId });
}
/**
* List an agent's publish history, newest first. `schema`/`tools`/`skills`
* are intentionally omitted the list view only needs metadata.
*/
async findByAgentId(agentId: string, take: number, skip: number): Promise<AgentHistory[]> {
return await this.find({
where: { agentId },
take,
skip,
order: { createdAt: 'DESC' },
select: {
versionId: true,
agentId: true,
createdAt: true,
updatedAt: true,
author: true,
},
});
}
/**
* Whether any history row exists for an agent. Backs the "has the agent
* ever been published" signal unlike `agent.activeVersionId !== null`,
* this stays true after an unpublish (which preserves the rows).
*/
async existsForAgent(agentId: string): Promise<boolean> {
return await this.existsBy({ agentId });
}
}

View File

@ -6132,6 +6132,24 @@
"agents.revertToPublished.modal.title": "Revert changes?",
"agents.revertToPublished.modal.description": "This will permanently remove all changes made since the latest published version.",
"agents.revertToPublished.modal.button.revert": "Revert changes",
"agents.versionHistory.title": "Publish history",
"agents.versionHistory.button.tooltip": "Version history",
"agents.versionHistory.button.tooltip.empty": "This agent currently has no history to view. Once you've published it for the first time, you'll be able to view previous versions",
"agents.versionHistory.button.ariaLabel": "Open version history",
"agents.versionHistory.close": "Close",
"agents.versionHistory.empty": "No versions yet. Publish your agent to start a history.",
"agents.versionHistory.error.load": "Could not load version history",
"agents.versionHistory.item.createdAt": "{date} at {time}",
"agents.versionHistory.item.publishedBadge": "Currently published",
"agents.versionHistory.item.actions.revert": "Revert to this version",
"agents.versionHistory.item.actions.publish": "Publish this version",
"agents.versionHistory.revert.modal.title": "Revert to this version?",
"agents.versionHistory.revert.modal.description": "This will replace your current draft with the contents of this version. Unsaved changes will be lost.",
"agents.versionHistory.revert.modal.button.revert": "Revert draft",
"agents.versionHistory.revert.toast.title": "Reverted to version",
"agents.versionHistory.revert.error": "Could not revert to the selected version",
"agents.versionHistory.publish.toast.title": "Version published",
"agents.versionHistory.publish.error": "Could not publish the selected version",
"agents.search.placeholder": "Search agents...",
"agents.tools.title": "Tools",
"agents.tools.add": "Add tool",

View File

@ -0,0 +1,106 @@
/* eslint-disable import-x/no-extraneous-dependencies, @typescript-eslint/no-unsafe-assignment -- test-only patterns */
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { mount, flushPromises } from '@vue/test-utils';
import { ref } from 'vue';
vi.mock('@n8n/i18n', () => ({
useI18n: () => ({ baseText: (key: string) => key }),
}));
const versionHistoryMock = {
items: ref([]),
isLoading: ref(false),
isInitialLoad: ref(false),
hasMore: ref(false),
refresh: vi.fn().mockResolvedValue(undefined),
fetchMore: vi.fn(),
revertToVersion: vi.fn(),
publishVersion: vi.fn(),
};
vi.mock('../composables/useAgentVersionHistory', () => ({
useAgentVersionHistory: () => versionHistoryMock,
}));
const agentPermissionsMock = {
canCreate: ref(true),
canUpdate: ref(true),
canDelete: ref(true),
canPublish: ref(true),
canUnpublish: ref(true),
};
vi.mock('../composables/useAgentPermissions', () => ({
useAgentPermissions: () => agentPermissionsMock,
}));
const STUBS = {
N8nHeading: { template: '<div><slot /></div>' },
N8nIconButton: {
template: '<button @click="$emit(\'click\')" />',
props: ['icon', 'type', 'size', 'title'],
emits: ['click'],
},
AgentVersionList: {
name: 'AgentVersionList',
template: '<div data-testid="stub-version-list" />',
props: ['items', 'actions', 'hasMore', 'isInitialLoad', 'isLoading'],
},
};
import AgentVersionHistoryPanel from '../components/VersionHistory/AgentVersionHistoryPanel.vue';
function mountPanel() {
return mount(AgentVersionHistoryPanel, {
props: { projectId: 'project-1', agentId: 'agent-1' },
global: { stubs: STUBS },
});
}
describe('AgentVersionHistoryPanel — RBAC gating of row actions', () => {
beforeEach(() => {
agentPermissionsMock.canUpdate.value = true;
agentPermissionsMock.canPublish.value = true;
vi.clearAllMocks();
});
it('exposes both revert and publish actions when the user has update and publish scopes', async () => {
const wrapper = mountPanel();
await flushPromises();
const list = wrapper.findComponent({ name: 'AgentVersionList' });
const actions = list.props('actions') as Array<{ value: string }>;
expect(actions.map((a) => a.value)).toEqual(['revert', 'publish']);
});
it('omits the revert action when the user cannot update', async () => {
agentPermissionsMock.canUpdate.value = false;
const wrapper = mountPanel();
await flushPromises();
const list = wrapper.findComponent({ name: 'AgentVersionList' });
const actions = list.props('actions') as Array<{ value: string }>;
expect(actions.map((a) => a.value)).toEqual(['publish']);
});
it('omits the publish action when the user cannot publish', async () => {
agentPermissionsMock.canPublish.value = false;
const wrapper = mountPanel();
await flushPromises();
const list = wrapper.findComponent({ name: 'AgentVersionList' });
const actions = list.props('actions') as Array<{ value: string }>;
expect(actions.map((a) => a.value)).toEqual(['revert']);
});
it('exposes no row actions for a read-only viewer', async () => {
agentPermissionsMock.canUpdate.value = false;
agentPermissionsMock.canPublish.value = false;
const wrapper = mountPanel();
await flushPromises();
const list = wrapper.findComponent({ name: 'AgentVersionList' });
const actions = list.props('actions') as Array<{ value: string }>;
expect(actions).toEqual([]);
});
});

View File

@ -0,0 +1,110 @@
/* eslint-disable import-x/no-extraneous-dependencies -- test-only patterns */
import { describe, it, expect } from 'vitest';
import { mount } from '@vue/test-utils';
import { vi } from 'vitest';
import type { AgentVersionListItemDto } from '@n8n/api-types';
vi.mock('@n8n/i18n', () => ({
useI18n: () => ({
baseText: (k: string, opts?: { interpolate?: Record<string, string> }) => {
if (!opts?.interpolate) return k;
return `${k}:${Object.values(opts.interpolate).join(',')}`;
},
}),
}));
vi.mock('@n8n/design-system', () => ({
N8nActionToggle: {
name: 'N8nActionToggle',
template:
'<div data-testid="stub-action-toggle" :data-actions="JSON.stringify(actions)" @click="$emit(\'action\', actions[0]?.value)" />',
props: ['actions', 'placement'],
emits: ['action'],
},
N8nText: {
name: 'N8nText',
template: '<span><slot /></span>',
props: ['size', 'bold', 'color', 'tag'],
},
}));
import AgentVersionListItem from '../components/VersionHistory/AgentVersionListItem.vue';
function makeItem(overrides: Partial<AgentVersionListItemDto> = {}): AgentVersionListItemDto {
return {
versionId: 'abcdef1234567890',
agentId: 'agent-1',
createdAt: '2026-01-02T10:11:12.000Z',
updatedAt: '2026-01-02T10:11:12.000Z',
author: 'Ada Lovelace',
isActive: false,
...overrides,
};
}
const actions = [
{ label: 'revert', value: 'revert', disabled: false },
{ label: 'publish', value: 'publish', disabled: false },
];
describe('AgentVersionListItem', () => {
it('renders the version label, author, and timestamp', () => {
const wrapper = mount(AgentVersionListItem, {
props: {
item: makeItem(),
actions,
},
});
expect(wrapper.text()).toContain('Version abcdef12');
expect(wrapper.text()).toContain('Ada Lovelace');
});
it('renders the published badge when the version is active', () => {
const wrapper = mount(AgentVersionListItem, {
props: {
item: makeItem({ isActive: true }),
actions,
},
});
expect(wrapper.text()).toContain('agents.versionHistory.item.publishedBadge');
});
it('does not render the published badge when the version is inactive', () => {
const wrapper = mount(AgentVersionListItem, {
props: {
item: makeItem({ isActive: false }),
actions,
},
});
expect(wrapper.text()).not.toContain('agents.versionHistory.item.publishedBadge');
});
it('emits an action event with the versionId when the action toggle fires', async () => {
const wrapper = mount(AgentVersionListItem, {
props: {
item: makeItem({ versionId: 'v-target' }),
actions,
},
});
await wrapper.findComponent({ name: 'N8nActionToggle' }).trigger('click');
const emitted = wrapper.emitted('action');
expect(emitted).toBeTruthy();
expect(emitted?.[0]?.[0]).toEqual({ action: 'revert', versionId: 'v-target' });
});
it('hides the action toggle when the actions list is empty', () => {
const wrapper = mount(AgentVersionListItem, {
props: {
item: makeItem({ isActive: true }),
actions: [],
},
});
expect(wrapper.find('[data-testid="stub-action-toggle"]').exists()).toBe(false);
});
});

View File

@ -0,0 +1,32 @@
/* eslint-disable import-x/no-extraneous-dependencies -- test-only patterns */
import { describe, it, expect } from 'vitest';
import { mount } from '@vue/test-utils';
import AgentVersionStatusIndicator from '../components/VersionHistory/AgentVersionStatusIndicator.vue';
describe('AgentVersionStatusIndicator', () => {
it('renders with the published modifier class when status="published"', () => {
const wrapper = mount(AgentVersionStatusIndicator, {
props: { status: 'published' },
});
const span = wrapper.find('span');
expect(span.classes().join(' ')).toMatch(/indicator-published/);
});
it('renders with the default modifier class when no status is supplied', () => {
const wrapper = mount(AgentVersionStatusIndicator);
const span = wrapper.find('span');
expect(span.classes().join(' ')).toMatch(/indicator-default/);
});
it('renders with the default modifier class when status="default"', () => {
const wrapper = mount(AgentVersionStatusIndicator, {
props: { status: 'default' },
});
const span = wrapper.find('span');
expect(span.classes().join(' ')).toMatch(/indicator-default/);
});
});

View File

@ -0,0 +1,269 @@
/* eslint-disable import-x/no-extraneous-dependencies -- test-only patterns */
import { describe, it, expect, vi, beforeEach } from 'vitest';
import type { AgentVersionListItemDto } from '@n8n/api-types';
const mocks = vi.hoisted(() => ({
listAgentVersions: vi.fn(),
publishAgent: vi.fn(),
revertAgentToVersion: vi.fn(),
openAgentConfirmationModal: vi.fn(),
showMessage: vi.fn(),
showError: vi.fn(),
}));
vi.mock('../composables/useAgentApi', () => ({
listAgentVersions: mocks.listAgentVersions,
publishAgent: mocks.publishAgent,
revertAgentToVersion: mocks.revertAgentToVersion,
}));
vi.mock('../composables/useAgentConfirmationModal', () => ({
useAgentConfirmationModal: () => ({
openAgentConfirmationModal: mocks.openAgentConfirmationModal,
}),
}));
vi.mock('@/app/composables/useToast', () => ({
useToast: () => ({
showMessage: mocks.showMessage,
showError: mocks.showError,
}),
}));
vi.mock('@n8n/i18n', () => ({
useI18n: () => ({ baseText: (k: string) => k }),
}));
vi.mock('@n8n/stores/useRootStore', () => ({
useRootStore: () => ({ restApiContext: { baseUrl: '/rest', pushRef: 'ref' } }),
}));
vi.mock('@/app/constants', () => ({
MODAL_CONFIRM: 'confirm',
}));
import { useAgentVersionHistory } from '../composables/useAgentVersionHistory';
function deferred<T>() {
let resolve!: (value: T) => void;
const promise = new Promise<T>((res) => {
resolve = res;
});
return { promise, resolve };
}
function makeVersion(versionId: string, overrides: Partial<AgentVersionListItemDto> = {}) {
return {
versionId,
agentId: 'agent-1',
createdAt: '2026-01-01T00:00:00.000Z',
updatedAt: '2026-01-01T00:00:00.000Z',
author: 'Ada Lovelace',
isActive: false,
...overrides,
} satisfies AgentVersionListItemDto;
}
describe('useAgentVersionHistory', () => {
beforeEach(() => {
vi.clearAllMocks();
});
describe('refresh', () => {
it('fetches the first page and populates items', async () => {
const rows = [makeVersion('v2', { isActive: true }), makeVersion('v1')];
mocks.listAgentVersions.mockResolvedValue(rows);
const history = useAgentVersionHistory();
await history.refresh('project-1', 'agent-1');
expect(mocks.listAgentVersions).toHaveBeenCalledWith(
expect.anything(),
'project-1',
'agent-1',
{ take: 20, skip: 0 },
);
expect(history.items.value).toEqual(rows);
expect(history.isInitialLoad.value).toBe(false);
expect(history.hasMore.value).toBe(false);
});
it('marks hasMore when the page is full', async () => {
const rows = Array.from({ length: 20 }, (_, i) => makeVersion(`v${i}`));
mocks.listAgentVersions.mockResolvedValue(rows);
const history = useAgentVersionHistory();
await history.refresh('project-1', 'agent-1');
expect(history.hasMore.value).toBe(true);
});
it('shows an error toast and leaves items unchanged on failure', async () => {
mocks.listAgentVersions.mockRejectedValue(new Error('boom'));
const history = useAgentVersionHistory();
await history.refresh('project-1', 'agent-1');
expect(mocks.showError).toHaveBeenCalledWith(
expect.any(Error),
'agents.versionHistory.error.load',
);
expect(history.items.value).toEqual([]);
});
it('keeps the newest result when an older refresh resolves out of order', async () => {
const stale = deferred<AgentVersionListItemDto[]>();
const fresh = deferred<AgentVersionListItemDto[]>();
mocks.listAgentVersions.mockReturnValueOnce(stale.promise).mockReturnValueOnce(fresh.promise);
const staleRows = [makeVersion('old')];
const freshRows = [makeVersion('new')];
const history = useAgentVersionHistory();
// Two overlapping loads, e.g. the panel's prop watcher firing on an
// agent switch before the first request has returned.
const first = history.refresh('project-1', 'agent-1');
const second = history.refresh('project-1', 'agent-2');
// Newer request settles first, then the older one resolves late.
fresh.resolve(freshRows);
await second;
stale.resolve(staleRows);
await first;
expect(history.items.value).toEqual(freshRows);
expect(history.isLoading.value).toBe(false);
});
});
describe('fetchMore', () => {
it('appends the next page using items.length as skip', async () => {
const firstPage = Array.from({ length: 20 }, (_, i) => makeVersion(`v${20 - i}`));
const secondPage = [makeVersion('vA'), makeVersion('vB')];
mocks.listAgentVersions.mockResolvedValueOnce(firstPage).mockResolvedValueOnce(secondPage);
const history = useAgentVersionHistory();
await history.refresh('project-1', 'agent-1');
await history.fetchMore('project-1', 'agent-1');
expect(mocks.listAgentVersions).toHaveBeenNthCalledWith(
2,
expect.anything(),
'project-1',
'agent-1',
{ take: 20, skip: 20 },
);
expect(history.items.value).toEqual([...firstPage, ...secondPage]);
});
it('is a no-op when hasMore is false', async () => {
mocks.listAgentVersions.mockResolvedValue([makeVersion('v1')]);
const history = useAgentVersionHistory();
await history.refresh('project-1', 'agent-1');
expect(history.hasMore.value).toBe(false);
await history.fetchMore('project-1', 'agent-1');
expect(mocks.listAgentVersions).toHaveBeenCalledTimes(1);
});
});
describe('revertToVersion', () => {
it('opens the confirmation modal before calling the API', async () => {
mocks.openAgentConfirmationModal.mockResolvedValue('cancel');
mocks.listAgentVersions.mockResolvedValue([]);
const history = useAgentVersionHistory();
const result = await history.revertToVersion('project-1', 'agent-1', 'v1');
expect(mocks.openAgentConfirmationModal).toHaveBeenCalledWith(
expect.objectContaining({
title: 'agents.versionHistory.revert.modal.title',
description: 'agents.versionHistory.revert.modal.description',
}),
);
expect(mocks.revertAgentToVersion).not.toHaveBeenCalled();
expect(result).toBeNull();
});
it('calls the API, refreshes the list, and shows a toast on confirm', async () => {
mocks.openAgentConfirmationModal.mockResolvedValue('confirm');
const updatedAgent = { id: 'agent-1' };
mocks.revertAgentToVersion.mockResolvedValue(updatedAgent);
mocks.listAgentVersions.mockResolvedValue([makeVersion('v1')]);
const history = useAgentVersionHistory();
const result = await history.revertToVersion('project-1', 'agent-1', 'v1');
expect(mocks.revertAgentToVersion).toHaveBeenCalledWith(
expect.anything(),
'project-1',
'agent-1',
'v1',
);
expect(mocks.showMessage).toHaveBeenCalledWith(
expect.objectContaining({
title: 'agents.versionHistory.revert.toast.title',
type: 'success',
}),
);
expect(mocks.listAgentVersions).toHaveBeenCalledTimes(1);
expect(result).toBe(updatedAgent);
});
it('surfaces an error toast and returns null when the API fails', async () => {
mocks.openAgentConfirmationModal.mockResolvedValue('confirm');
mocks.revertAgentToVersion.mockRejectedValue(new Error('500'));
const history = useAgentVersionHistory();
const result = await history.revertToVersion('project-1', 'agent-1', 'v1');
expect(mocks.showError).toHaveBeenCalledWith(
expect.any(Error),
'agents.versionHistory.revert.error',
);
expect(result).toBeNull();
});
});
describe('publishVersion', () => {
it('calls publishAgent with versionId, refreshes the list, and shows a toast', async () => {
const updatedAgent = { id: 'agent-1', activeVersionId: 'v1' };
mocks.publishAgent.mockResolvedValue(updatedAgent);
mocks.listAgentVersions.mockResolvedValue([makeVersion('v1', { isActive: true })]);
const history = useAgentVersionHistory();
const result = await history.publishVersion('project-1', 'agent-1', 'v1');
expect(mocks.publishAgent).toHaveBeenCalledWith(
expect.anything(),
'project-1',
'agent-1',
'v1',
);
expect(mocks.openAgentConfirmationModal).not.toHaveBeenCalled();
expect(mocks.showMessage).toHaveBeenCalledWith(
expect.objectContaining({
title: 'agents.versionHistory.publish.toast.title',
type: 'success',
}),
);
expect(result).toBe(updatedAgent);
});
it('surfaces an error toast and returns null when the API fails', async () => {
mocks.publishAgent.mockRejectedValue(new Error('500'));
const history = useAgentVersionHistory();
const result = await history.publishVersion('project-1', 'agent-1', 'v1');
expect(mocks.showError).toHaveBeenCalledWith(
expect.any(Error),
'agents.versionHistory.publish.error',
);
expect(result).toBeNull();
});
});
});

View File

@ -27,6 +27,7 @@ export type Agent = {
projectId: string;
isCompiled: boolean;
isRunnable?: boolean;
hasPublishHistory?: boolean;
createdAt: string;
updatedAt: string;
versionId: string | null;

View File

@ -39,6 +39,7 @@ const props = defineProps<{
currentSessionTitle?: string;
sessionOptions?: Array<DropdownMenuItemProps<string>>;
beforeRevertToPublished?: () => Promise<void> | void;
isVersionHistoryOpen?: boolean;
}>();
const emit = defineEmits<{
@ -51,6 +52,7 @@ const emit = defineEmits<{
unpublished: [agent: AgentResource];
reverted: [agent: AgentResource];
'switch-agent': [agentId: string];
'toggle-version-history': [];
}>();
const i18n = useI18n();
@ -133,6 +135,11 @@ function onOpenPreview() {
if (isPreviewDisabled.value) return;
emit('open-preview');
}
// Disabled until the agent has at least one publish history row. The flag
// is set by the backend (see AgentsService.hasPublishHistory) so it stays
// true after an unpublish, when activeVersionId is null but rows persist.
const isVersionHistoryDisabled = computed(() => !props.agent?.hasPublishHistory);
</script>
<template>
@ -256,6 +263,27 @@ function onOpenPreview() {
@unpublished="(a: AgentResource) => emit('unpublished', a)"
@reverted="(a: AgentResource) => emit('reverted', a)"
/>
<N8nTooltip placement="bottom">
<template #content>
<span v-if="isVersionHistoryDisabled">
{{ i18n.baseText('agents.versionHistory.button.tooltip.empty') }}
</span>
<span v-else>
{{ i18n.baseText('agents.versionHistory.title') }}
</span>
</template>
<N8nButton
variant="ghost"
size="medium"
icon="history"
icon-only
:class="{ [$style.activeButton]: isVersionHistoryOpen }"
:disabled="isVersionHistoryDisabled"
:aria-label="i18n.baseText('agents.versionHistory.button.ariaLabel')"
data-testid="agent-header-version-history-btn"
@click="emit('toggle-version-history')"
/>
</N8nTooltip>
<N8nActionDropdown
v-if="headerActions.length > 0"
:items="headerActions"
@ -349,4 +377,8 @@ function onOpenPreview() {
calc(100vw - var(--spacing--xl))
) !important;
}
.activeButton {
background-color: var(--background--active);
}
</style>

View File

@ -0,0 +1,153 @@
<script setup lang="ts">
import { computed, onMounted, toRef, watch } from 'vue';
import type { UserAction } from '@n8n/design-system';
import { N8nHeading, N8nIconButton, N8nTooltip } from '@n8n/design-system';
import type { IUser } from 'n8n-workflow';
import { useI18n } from '@n8n/i18n';
import { useAgentVersionHistory } from '../../composables/useAgentVersionHistory';
import { useAgentPermissions } from '../../composables/useAgentPermissions';
import type { AgentResource } from '../../types';
import AgentVersionList from './AgentVersionList.vue';
import type { AgentVersionAction } from './AgentVersionListItem.vue';
const props = defineProps<{
projectId: string;
agentId: string;
}>();
const emit = defineEmits<{
close: [];
reverted: [agent: AgentResource];
published: [agent: AgentResource];
}>();
const i18n = useI18n();
const {
items,
isLoading,
isInitialLoad,
hasMore,
refresh,
fetchMore,
revertToVersion,
publishVersion,
} = useAgentVersionHistory();
const { canUpdate, canPublish } = useAgentPermissions(toRef(props, 'projectId'));
// Hide actions the user can't perform server-side. Matches the gating in
// AgentPublishButton so viewers with `agent:read` only don't see options that
// would 403 on click.
const actions = computed<Array<UserAction<IUser>>>(() => {
const result: Array<UserAction<IUser>> = [];
if (canUpdate.value) {
result.push({
label: i18n.baseText('agents.versionHistory.item.actions.revert'),
value: 'revert',
disabled: false,
});
}
if (canPublish.value) {
result.push({
label: i18n.baseText('agents.versionHistory.item.actions.publish'),
value: 'publish',
disabled: false,
});
}
return result;
});
onMounted(() => {
void refresh(props.projectId, props.agentId);
});
watch(
() => [props.projectId, props.agentId],
() => {
void refresh(props.projectId, props.agentId);
},
);
async function onAction({ action, versionId }: { action: AgentVersionAction; versionId: string }) {
if (action === 'revert') {
const result = await revertToVersion(props.projectId, props.agentId, versionId);
if (result) emit('reverted', result);
} else {
const result = await publishVersion(props.projectId, props.agentId, versionId);
if (result) emit('published', result);
}
}
function onLoadMore() {
void fetchMore(props.projectId, props.agentId);
}
function onClose() {
emit('close');
}
// Exposed so the parent view can re-fetch the list after publish/unpublish
// events from elsewhere in the editor (e.g. the header's publish button)
// otherwise the green-dot indicator would lag the agent's actual state.
defineExpose({
refresh: () => refresh(props.projectId, props.agentId),
});
</script>
<template>
<aside :class="$style.panel" data-test-id="agent-version-history-panel">
<header :class="$style.header">
<N8nHeading size="small" :bold="true">
{{ i18n.baseText('agents.versionHistory.title') }}
</N8nHeading>
<N8nTooltip :content="i18n.baseText('agents.versionHistory.close')">
<N8nIconButton
icon="x"
variant="ghost"
size="medium"
:class="$style.closeButton"
:title="i18n.baseText('agents.versionHistory.close')"
data-test-id="agent-version-history-close"
@click="onClose"
/>
</N8nTooltip>
</header>
<AgentVersionList
:items="items"
:actions="actions"
:has-more="hasMore"
:is-initial-load="isInitialLoad"
:is-loading="isLoading"
@action="onAction"
@load-more="onLoadMore"
/>
</aside>
</template>
<style module lang="scss">
.panel {
display: flex;
flex-direction: column;
width: 330px;
min-width: 330px;
height: 100%;
background-color: var(--background--surface);
border-left: var(--border);
}
.header {
display: flex;
align-items: center;
justify-content: space-between;
padding: var(--spacing--xs);
border-bottom: var(--border);
border-color: var(--color--foreground--tint-2);
flex-shrink: 0;
}
.closeButton {
flex-shrink: 0;
margin-right: var(--spacing--3xs);
}
</style>

View File

@ -0,0 +1,111 @@
<script setup lang="ts">
import { ref, computed, watch } from 'vue';
import type { AgentVersionListItemDto } from '@n8n/api-types';
import type { UserAction } from '@n8n/design-system';
import { N8nLoading, N8nText } from '@n8n/design-system';
import type { IUser } from 'n8n-workflow';
import { useI18n } from '@n8n/i18n';
import { useIntersectionObserver } from '@/app/composables/useIntersectionObserver';
import AgentVersionListItem, { type AgentVersionAction } from './AgentVersionListItem.vue';
const props = defineProps<{
items: AgentVersionListItemDto[];
actions: Array<UserAction<IUser>>;
hasMore: boolean;
isInitialLoad: boolean;
isLoading: boolean;
}>();
const emit = defineEmits<{
action: [value: { action: AgentVersionAction; versionId: string }];
loadMore: [];
}>();
const i18n = useI18n();
const listElement = ref<HTMLElement | null>(null);
const loadMoreSentinel = ref<HTMLElement | null>(null);
const { observe: observeForLoadMore } = useIntersectionObserver({
root: listElement,
threshold: 0.01,
onIntersect: () => emit('loadMore'),
});
watch(
[loadMoreSentinel, () => props.hasMore, () => props.items.length],
([sentinel, canLoadMore]) => {
if (sentinel && canLoadMore) {
observeForLoadMore(sentinel);
}
},
{ immediate: true },
);
const isEmpty = computed(
() => !props.isInitialLoad && !props.isLoading && props.items.length === 0,
);
function getActions(item: AgentVersionListItemDto) {
// Hide both Revert and Publish on the currently-active row neither does
// anything meaningful (revert would be a no-op, publish is already active).
return item.isActive ? [] : props.actions;
}
</script>
<template>
<div ref="listElement" :class="$style.list" data-test-id="agent-version-history-list">
<N8nLoading v-if="isInitialLoad" :loading="true" :rows="6" animated />
<div v-else-if="isEmpty" :class="$style.empty" data-test-id="agent-version-history-empty">
<N8nText size="small" color="text-base">
{{ i18n.baseText('agents.versionHistory.empty') }}
</N8nText>
</div>
<ul v-else :class="$style.items">
<AgentVersionListItem
v-for="item in items"
:key="item.versionId"
:item="item"
:actions="getActions(item)"
@action="(value) => emit('action', value)"
/>
<li
v-if="hasMore"
ref="loadMoreSentinel"
:class="$style.sentinel"
data-test-id="agent-version-history-sentinel"
>
<N8nLoading v-if="isLoading" :loading="true" :rows="1" animated />
</li>
</ul>
</div>
</template>
<style module lang="scss">
.list {
flex: 1 1 auto;
min-height: 0;
overflow-y: auto;
padding: var(--spacing--2xs);
}
.empty {
display: flex;
align-items: center;
justify-content: center;
height: 100%;
padding: var(--spacing--lg);
text-align: center;
}
.items {
list-style: none;
padding: 0;
margin: 0;
}
.sentinel {
padding: var(--spacing--xs) 0;
}
</style>

View File

@ -0,0 +1,147 @@
<script setup lang="ts">
import { computed } from 'vue';
import type { AgentVersionListItemDto } from '@n8n/api-types';
import type { UserAction } from '@n8n/design-system';
import { N8nActionToggle, N8nText } from '@n8n/design-system';
import type { IUser } from 'n8n-workflow';
import { useI18n } from '@n8n/i18n';
import AgentVersionStatusIndicator from './AgentVersionStatusIndicator.vue';
import { formatTimestamp, generateVersionLabel } from './agentVersionHistory.utils';
export type AgentVersionAction = 'revert' | 'publish';
const props = defineProps<{
item: AgentVersionListItemDto;
actions: Array<UserAction<IUser>>;
}>();
const emit = defineEmits<{
action: [value: { action: AgentVersionAction; versionId: string }];
}>();
const i18n = useI18n();
const versionLabel = computed(() => generateVersionLabel(props.item.versionId));
const formattedCreatedAt = computed(() => {
const { date, time } = formatTimestamp(props.item.createdAt);
return i18n.baseText('agents.versionHistory.item.createdAt', {
interpolate: { date, time },
});
});
const status = computed<'published' | 'default'>(() =>
props.item.isActive ? 'published' : 'default',
);
function onAction(value: string) {
emit('action', {
action: value as AgentVersionAction,
versionId: props.item.versionId,
});
}
</script>
<template>
<li :class="$style.item" data-test-id="agent-version-history-list-item">
<span :class="$style.timelineColumn">
<AgentVersionStatusIndicator :status="status" />
</span>
<div :class="$style.content">
<div :class="$style.mainRow">
<N8nText size="small" :bold="true" color="text-dark" :class="$style.mainLine">
{{ versionLabel }}
<template v-if="item.isActive">
({{ i18n.baseText('agents.versionHistory.item.publishedBadge') }})
</template>
</N8nText>
</div>
<div :class="$style.metaRow">
<N8nText size="small" color="text-base" :class="$style.metaAuthor">
{{ item.author }},
</N8nText>
<N8nText tag="time" size="small" color="text-base" :class="$style.metaTime">
{{ formattedCreatedAt }}
</N8nText>
</div>
</div>
<N8nActionToggle
v-if="actions.length > 0"
:class="$style.actions"
:actions="actions"
placement="bottom-end"
data-test-id="agent-version-history-item-actions"
@action="onAction"
@click.stop
/>
</li>
</template>
<style module lang="scss">
.item {
display: flex;
position: relative;
align-items: center;
gap: var(--spacing--2xs);
padding: var(--spacing--2xs);
line-height: var(--line-height--xl);
border-radius: var(--radius);
user-select: none;
& + & {
margin-top: var(--spacing--xs);
}
}
.timelineColumn {
display: flex;
align-items: center;
justify-content: center;
width: var(--spacing--lg);
min-width: var(--spacing--lg);
}
.content {
display: flex;
flex-direction: column;
flex: 1 1 auto;
min-width: 0;
}
.mainRow {
display: flex;
align-items: center;
gap: var(--spacing--2xs);
}
.mainLine {
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
.metaRow {
display: flex;
align-items: baseline;
gap: var(--spacing--3xs);
min-width: 0;
}
.metaAuthor {
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
max-width: 130px;
}
.metaTime {
flex-shrink: 0;
}
.actions {
flex-shrink: 0;
margin-right: var(--spacing--3xs);
}
</style>

View File

@ -0,0 +1,33 @@
<script setup lang="ts">
withDefaults(
defineProps<{
status?: 'published' | 'default';
}>(),
{
status: 'default',
},
);
</script>
<template>
<span :class="[$style.indicator, $style[`indicator-${status}`]]" />
</template>
<style module lang="scss">
.indicator {
display: inline-block;
width: var(--spacing--2xs);
height: var(--spacing--2xs);
border-radius: 50%;
flex-shrink: 0;
}
.indicator-published {
background-color: var(--color--mint-600);
}
.indicator-default {
border: var(--border);
border-color: var(--color--text--tint-2);
}
</style>

View File

@ -0,0 +1,15 @@
import dateformat from 'dateformat';
export function generateVersionLabel(versionId: string): string {
return `Version ${versionId.substring(0, 8)}`;
}
export function formatTimestamp(value: string): { date: string; time: string } {
const currentYear = new Date().getFullYear().toString();
const [date, time] = dateformat(
value,
`${value.startsWith(currentYear) ? '' : 'yyyy '}mmm d"#"HH:MM:ss`,
).split('#');
return { date, time };
}

View File

@ -6,6 +6,7 @@ import type {
AgentSkillMutationResponse,
AgentScheduleConfig,
AgentIntegrationSettings,
AgentVersionListItemDto,
ChatIntegrationDescriptor,
CreateSlackAgentAppResponse,
SlackAgentAppManifestResponse,
@ -250,11 +251,13 @@ export const publishAgent = async (
context: IRestApiContext,
projectId: string,
agentId: string,
versionId?: string,
): Promise<AgentResource> => {
return await makeRestApiRequest<AgentResource>(
context,
'POST',
`/projects/${projectId}/agents/v2/${agentId}/publish`,
versionId ? { versionId } : undefined,
);
};
@ -282,6 +285,34 @@ export const revertAgentToPublished = async (
);
};
export const revertAgentToVersion = async (
context: IRestApiContext,
projectId: string,
agentId: string,
versionId: string,
): Promise<AgentResource> => {
return await makeRestApiRequest<AgentResource>(
context,
'POST',
`/projects/${projectId}/agents/v2/${agentId}/revert-to-version`,
{ versionId },
);
};
export const listAgentVersions = async (
context: IRestApiContext,
projectId: string,
agentId: string,
params: { take: number; skip: number },
): Promise<AgentVersionListItemDto[]> => {
return await makeRestApiRequest<AgentVersionListItemDto[]>(
context,
'GET',
`/projects/${projectId}/agents/v2/${agentId}/versions`,
params,
);
};
export const getAgentConfig = async (
context: IRestApiContext,
projectId: string,

View File

@ -0,0 +1,156 @@
import { ref } from 'vue';
import type { AgentVersionListItemDto } from '@n8n/api-types';
import { useI18n } from '@n8n/i18n';
import { useRootStore } from '@n8n/stores/useRootStore';
import { useToast } from '@/app/composables/useToast';
import { MODAL_CONFIRM } from '@/app/constants';
import { listAgentVersions, publishAgent, revertAgentToVersion } from './useAgentApi';
import { useAgentConfirmationModal } from './useAgentConfirmationModal';
import type { AgentResource } from '../types';
const PAGE_SIZE = 20;
/**
* Owns the version-history list state (items, pagination cursor, loading flags)
* and the two row actions revert-to-version (with confirmation modal) and
* publish-this-version. Both actions refresh the list on success; the caller
* is responsible for refetching the agent so the editor reflects any draft
* changes from a revert.
*/
export function useAgentVersionHistory() {
const rootStore = useRootStore();
const locale = useI18n();
const { showMessage, showError } = useToast();
const { openAgentConfirmationModal } = useAgentConfirmationModal();
const items = ref<AgentVersionListItemDto[]>([]);
const isLoading = ref(false);
const isInitialLoad = ref(true);
const hasMore = ref(true);
const acting = ref(false);
// Guards against out-of-order responses. Each load captures the current
// token; when its request settles it only writes to shared state if the
// token is still the latest. A newer load supersedes an older in-flight one
// (e.g. switching agents while the panel is open, which re-fires the load via
// a prop watcher), so a slow earlier response can't overwrite fresher data.
let requestToken = 0;
async function refresh(projectId: string, agentId: string): Promise<void> {
const token = ++requestToken;
isLoading.value = true;
try {
const data = await listAgentVersions(rootStore.restApiContext, projectId, agentId, {
take: PAGE_SIZE,
skip: 0,
});
if (token !== requestToken) return;
items.value = data;
hasMore.value = data.length === PAGE_SIZE;
} catch (error) {
if (token !== requestToken) return;
showError(error, locale.baseText('agents.versionHistory.error.load'));
} finally {
if (token === requestToken) {
isLoading.value = false;
isInitialLoad.value = false;
}
}
}
async function fetchMore(projectId: string, agentId: string): Promise<void> {
if (isLoading.value || !hasMore.value) return;
const token = ++requestToken;
isLoading.value = true;
try {
const data = await listAgentVersions(rootStore.restApiContext, projectId, agentId, {
take: PAGE_SIZE,
skip: items.value.length,
});
if (token !== requestToken) return;
items.value = [...items.value, ...data];
hasMore.value = data.length === PAGE_SIZE;
} catch (error) {
if (token !== requestToken) return;
showError(error, locale.baseText('agents.versionHistory.error.load'));
} finally {
if (token === requestToken) {
isLoading.value = false;
}
}
}
async function revertToVersion(
projectId: string,
agentId: string,
versionId: string,
): Promise<AgentResource | null> {
if (acting.value) return null;
const confirmed = await openAgentConfirmationModal({
title: locale.baseText('agents.versionHistory.revert.modal.title'),
description: locale.baseText('agents.versionHistory.revert.modal.description'),
confirmButtonText: locale.baseText('agents.versionHistory.revert.modal.button.revert'),
cancelButtonText: locale.baseText('generic.cancel'),
});
if (confirmed !== MODAL_CONFIRM) return null;
acting.value = true;
try {
const updated = await revertAgentToVersion(
rootStore.restApiContext,
projectId,
agentId,
versionId,
);
showMessage({
title: locale.baseText('agents.versionHistory.revert.toast.title'),
type: 'success',
});
await refresh(projectId, agentId);
return updated;
} catch (error) {
showError(error, locale.baseText('agents.versionHistory.revert.error'));
return null;
} finally {
acting.value = false;
}
}
async function publishVersion(
projectId: string,
agentId: string,
versionId: string,
): Promise<AgentResource | null> {
if (acting.value) return null;
acting.value = true;
try {
const updated = await publishAgent(rootStore.restApiContext, projectId, agentId, versionId);
showMessage({
title: locale.baseText('agents.versionHistory.publish.toast.title'),
type: 'success',
});
await refresh(projectId, agentId);
return updated;
} catch (error) {
showError(error, locale.baseText('agents.versionHistory.publish.error'));
return null;
} finally {
acting.value = false;
}
}
return {
items,
isLoading,
isInitialLoad,
hasMore,
acting,
pageSize: PAGE_SIZE,
refresh,
fetchMore,
revertToVersion,
publishVersion,
};
}

View File

@ -56,6 +56,7 @@ import AgentBuilderHeader from '../components/AgentBuilderHeader.vue';
import AgentBuilderChatColumn from '../components/AgentBuilderChatColumn.vue';
import AgentBuilderEditorColumn from '../components/AgentBuilderEditorColumn.vue';
import AgentPreviewChatPage from '../components/AgentPreviewChatPage.vue';
import AgentVersionHistoryPanel from '../components/VersionHistory/AgentVersionHistoryPanel.vue';
const AGENT_CHAT_PANEL_MIN_WIDTH = 320;
const AGENT_CHAT_PANEL_DEFAULT_WIDTH = 460;
@ -88,6 +89,7 @@ const { canUpdate: canEditAgent, canDelete: canDeleteAgent } = useAgentPermissio
// UI state
const isBuildChatStreaming = ref(false);
const initialPrompt = ref<string | undefined>();
const isVersionHistoryOpen = ref(false);
function onBuildChatStreamingChange(streaming: boolean) {
isBuildChatStreaming.value = streaming;
@ -132,6 +134,7 @@ const { config, fetchConfig, updateConfig } = useAgentConfig();
const localConfig = ref<AgentJsonConfig | null>(null);
const connectedTriggers = ref<string[]>([]);
const builderContainer = useTemplateRef<HTMLElement>('builderContainer');
const versionHistoryPanel = useTemplateRef<{ refresh: () => Promise<void> }>('versionHistoryPanel');
const isChatFullWidth = ref(false);
const executionsCount = computed(() => sessionsStore.threads.length);
const { activeMainTab, mainTabOptions, executionsDescription } = useAgentBuilderMainTabs({
@ -298,10 +301,26 @@ function startChat(msg: string) {
function onPublished(updated: AgentResource) {
agent.value = updated;
void versionHistoryPanel.value?.refresh();
}
function onUnpublished(updated: AgentResource) {
agent.value = updated;
void versionHistoryPanel.value?.refresh();
}
function onToggleVersionHistory() {
const next = !isVersionHistoryOpen.value;
if (next && isChatFullWidth.value) {
// Make room for the panel chat-full-width hides the editor column
// and would leave the resizer at 100%, squashing the new panel.
isChatFullWidth.value = false;
}
isVersionHistoryOpen.value = next;
}
function onCloseVersionHistory() {
isVersionHistoryOpen.value = false;
}
async function onReverted(updated: AgentResource) {
@ -937,6 +956,7 @@ function onSwitchAgent(nextAgentId: string) {
:current-session-title="currentSessionTitle"
:session-options="sessionOptions"
:before-revert-to-published="settleAutosave"
:is-version-history-open="isVersionHistoryOpen"
@header-action="onHeaderAction"
@open-preview="onOpenPreview"
@new-chat="onNewChat"
@ -946,6 +966,7 @@ function onSwitchAgent(nextAgentId: string) {
@unpublished="onUnpublished"
@reverted="onReverted"
@switch-agent="onSwitchAgent"
@toggle-version-history="onToggleVersionHistory"
/>
<div
ref="builderContainer"
@ -1039,6 +1060,16 @@ function onSwitchAgent(nextAgentId: string) {
@update:connected-triggers="onConnectedTriggersUpdate"
@trigger-added="onTriggerAdded"
/>
<AgentVersionHistoryPanel
v-if="!isPreviewMode && isVersionHistoryOpen"
ref="versionHistoryPanel"
:project-id="projectId"
:agent-id="agentId"
@close="onCloseVersionHistory"
@reverted="onReverted"
@published="onPublished"
/>
</div>
</div>
</template>