mirror of
https://github.com/n8n-io/n8n.git
synced 2026-06-03 10:17:00 +02:00
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:
parent
1a9a69a9e6
commit
be3241dc22
|
|
@ -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),
|
||||
}) {}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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>();
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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([]);
|
||||
});
|
||||
});
|
||||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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/);
|
||||
});
|
||||
});
|
||||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -27,6 +27,7 @@ export type Agent = {
|
|||
projectId: string;
|
||||
isCompiled: boolean;
|
||||
isRunnable?: boolean;
|
||||
hasPublishHistory?: boolean;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
versionId: string | null;
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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 };
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user