Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 3 additions & 38 deletions api/src/slices/agent/agent/agent.controller.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -313,44 +313,9 @@ export class AgentController {
@Roles(UserRoleTypes.Owner, UserRoleTypes.Admin)
@ApiOperation({ summary: 'Create and deploy a new agent. Admin or Owner.' })
async create(@Body() dto: CreateAgentDto) {
const agent = await this.agentGateway.create(dto);
try {
const copied = await this.fileGateway.seedFromTemplate(
agent.id,
agent.templateId,
);
if (copied > 0) {
this.logger.log(
`Seeded ${copied} files into agent ${agent.id} from template ${agent.templateId}`,
);
}
} catch (err) {
this.logger.warn(
`Template seed skipped for agent ${agent.id}: ${(err as Error).message}`,
);
}
await this.agentDeployService.syncSkillsFromTemplate(
agent.id,
agent.templateId,
);
// Promote BEFORE the first deploy so the workflow boots the pod with
// RANCH_ADMIN=true on the first try — avoids the race of "create deploys
// non-admin → promote cancels + redeploys" where the cancel sometimes
// doesn't replace the running pod fast enough.
if (dto.isAdmin === true) {
const previous = await this.agentGateway.findAdmin();
if (previous && previous.id !== agent.id) {
await this.agentGateway.setAdmin(previous.id, false);
await this.agentDeployService.detachAndCancelWorkflow(
previous.id,
previous.workflowId,
);
await this.deploy(previous.id);
}
await this.agentGateway.setAdmin(agent.id, true);
}
await this.deploy(agent.id);
return this.agentGateway.findById(agent.id);
// Full sequence (seed → skills → promote-before-deploy → deploy) lives in
// AgentDeployService.createAgent, shared with the rancher create_agent tool.
return this.agentDeployService.createAgent(dto, { isAdmin: dto.isAdmin });
}

@Put(':id')
Expand Down
3 changes: 1 addition & 2 deletions api/src/slices/agent/agent/data/agent.mapper.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -15,8 +15,7 @@ export class AgentMapper {
workflowId: record.workflowId,
firstDeployedAt: record.firstDeployedAt,
lastDeployStartedAt: record.lastDeployStartedAt,
launchContext:
record.lastLaunchContext as IAgentData['launchContext'],
launchContext: record.lastLaunchContext as IAgentData['launchContext'],
lastPullAt: record.lastPullAt,
lastSyncAt: record.lastSyncAt,
config: record.config as unknown as Record<string, unknown>,
Expand Down
119 changes: 119 additions & 0 deletions api/src/slices/agent/agent/domain/agentDeploy.service.spec.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
import { AgentDeployService } from './agentDeploy.service';
import { IAgentGateway } from './agent.gateway';
import { IAgentData } from './agent.types';
import { IFileGateway } from '#/agent/file/domain';

const agent = (over: Partial<IAgentData> = {}): IAgentData =>
({
id: 'agent-new',
templateId: 'tpl-1',
workflowId: null,
isAdmin: false,
...over,
}) as IAgentData;

// Mocks live on a plain record (not behind the gateway abstract types) so
// expect(mocks.x) doesn't trip @typescript-eslint/unbound-method.
function build(over: { seedFromTemplate?: jest.Mock; findAdmin?: jest.Mock }) {
const mocks = {
create: jest.fn().mockResolvedValue(agent()),
findById: jest.fn().mockResolvedValue(agent()),
findAdmin: over.findAdmin ?? jest.fn().mockResolvedValue(null),
setAdmin: jest.fn().mockResolvedValue(agent()),
seedFromTemplate: over.seedFromTemplate ?? jest.fn().mockResolvedValue(3),
};
const agents = {
create: mocks.create,
findById: mocks.findById,
findAdmin: mocks.findAdmin,
setAdmin: mocks.setAdmin,
} as unknown as IAgentGateway;
const files = {
seedFromTemplate: mocks.seedFromTemplate,
} as unknown as IFileGateway;

const service = new AgentDeployService(
agents,
{} as never, // templateGateway — unused by createAgent
{} as never, // workflowService — reached only via mocked methods below
{} as never, // authService
files,
{} as never, // skillGateway
{} as never, // podGateway
{} as never, // deployTracker
);
// createAgent orchestrates; the heavy submethods have their own flows.
const deploy = jest
.spyOn(service, 'deploy')
.mockResolvedValue(undefined as never);
const syncSkills = jest
.spyOn(service, 'syncSkillsFromTemplate')
.mockResolvedValue(undefined as never);
const detach = jest
.spyOn(service, 'detachAndCancelWorkflow')
.mockResolvedValue(undefined as never);
return { service, mocks, deploy, syncSkills, detach };
}

describe('AgentDeployService.createAgent', () => {
it('creates, seeds template files, syncs skills, deploys — in order', async () => {
const { service, mocks, deploy, syncSkills } = build({});
const result = await service.createAgent({
name: 'a',
templateId: 'tpl-1',
});

expect(mocks.create).toHaveBeenCalledWith({
name: 'a',
templateId: 'tpl-1',
});
expect(mocks.seedFromTemplate).toHaveBeenCalledWith('agent-new', 'tpl-1');
expect(syncSkills).toHaveBeenCalledWith('agent-new', 'tpl-1');
expect(deploy).toHaveBeenCalledWith('agent-new');
expect(result).toEqual(agent());
// seed must precede deploy — the pod pulls S3 at boot.
const seedOrder = mocks.seedFromTemplate.mock.invocationCallOrder[0];
const deployOrder = deploy.mock.invocationCallOrder[0];
expect(seedOrder).toBeLessThan(deployOrder);
});

it('survives a failing template seed (best-effort) and still deploys', async () => {
const { service, deploy } = build({
seedFromTemplate: jest.fn().mockRejectedValue(new Error('s3 down')),
});
await expect(
service.createAgent({ name: 'a', templateId: 'tpl-1' }),
).resolves.toEqual(agent());
expect(deploy).toHaveBeenCalledWith('agent-new');
});

it('does not touch admin flags when isAdmin is not requested', async () => {
const { service, mocks } = build({});
await service.createAgent({ name: 'a', templateId: 'tpl-1' });
expect(mocks.findAdmin).not.toHaveBeenCalled();
expect(mocks.setAdmin).not.toHaveBeenCalled();
});

it('promotes BEFORE the first deploy and demotes+redeploys the previous admin', async () => {
const previous = agent({ id: 'agent-old', workflowId: 'wf-9' });
const { service, mocks, deploy, detach } = build({
findAdmin: jest.fn().mockResolvedValue(previous),
});
await service.createAgent(
{ name: 'a', templateId: 'tpl-1' },
{ isAdmin: true },
);

expect(mocks.setAdmin).toHaveBeenCalledWith('agent-old', false);
expect(detach).toHaveBeenCalledWith('agent-old', 'wf-9');
expect(deploy).toHaveBeenCalledWith('agent-old');
expect(mocks.setAdmin).toHaveBeenCalledWith('agent-new', true);
// Promote-before-deploy invariant: the new agent's first deploy must see
// RANCH_ADMIN=true (no promote-then-redeploy race).
const promoteOrder = mocks.setAdmin.mock.invocationCallOrder[1];
const newDeployOrder = deploy.mock.invocationCallOrder.find(
(_, i) => deploy.mock.calls[i][0] === 'agent-new',
);
expect(promoteOrder).toBeLessThan(newDeployOrder as number);
});
});
45 changes: 44 additions & 1 deletion api/src/slices/agent/agent/domain/agentDeploy.service.ts
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
import { Injectable, Logger } from '@nestjs/common';
import { IAgentGateway } from './agent.gateway';
import { IAgentData, ICreateAgentData } from './agent.types';
import { IFileGateway } from '#/agent/file/domain';
import { ITemplateGateway } from '#/agent/template/domain';
import { WorkflowService } from '#/workflow/domain/workflow.service';
Expand DownExpand Up@@ -58,6 +59,47 @@ export class AgentDeployService {
await this.deploy(agentId);
}

// Full creation sequence, shared by the REST controller and the rancher
// create_agent tool: DB row → template file seed (best-effort) → skills
// sync → optional admin promotion → first deploy. Promotion happens BEFORE
// the first deploy so the workflow boots the pod with RANCH_ADMIN=true on
// the first try — avoids the "create deploys non-admin → promote cancels +
// redeploys" race where the cancel sometimes doesn't replace the running
// pod fast enough.
async createAgent(
input: ICreateAgentData,
opts: { isAdmin?: boolean } = {},
): Promise<IAgentData | null> {
const agent = await this.agentGateway.create(input);
try {
const copied = await this.fileGateway.seedFromTemplate(
agent.id,
agent.templateId,
);
if (copied > 0) {
this.logger.log(
`Seeded ${copied} files into agent ${agent.id} from template ${agent.templateId}`,
);
}
} catch (err) {
this.logger.warn(
`Template seed skipped for agent ${agent.id}: ${(err as Error).message}`,
);
}
await this.syncSkillsFromTemplate(agent.id, agent.templateId);
if (opts.isAdmin === true) {
const previous = await this.agentGateway.findAdmin();
if (previous && previous.id !== agent.id) {
await this.agentGateway.setAdmin(previous.id, false);
await this.detachAndCancelWorkflow(previous.id, previous.workflowId);
await this.deploy(previous.id);
}
await this.agentGateway.setAdmin(agent.id, true);
}
await this.deploy(agent.id);
return this.agentGateway.findById(agent.id);
}

// Detach the workflow id from the agent row BEFORE cancelling it. A
// concurrent GET /agents/:id (syncStatus) polls the workflow referenced by
// the DB row; without this ordering it can catch the just-cancelled
Expand DownExpand Up@@ -136,7 +178,8 @@ export class AgentDeployService {
this.deployTracker.mark(agentId);
// 'initial' iff this agent has never been deployed — persisted so the UI
// can tell a first start from a restart even after a page reload.
const launchContext = agent.firstDeployedAt === null ? 'initial' : 'restart';
const launchContext =
agent.firstDeployedAt === null ? 'initial' : 'restart';
// Mark deploying BEFORE submitting the workflow. Submit + getStatus take
// seconds — long enough for the pod to come up and AgentStatusService to
// flip status to 'running'. If we wrote status here after submit we'd
Expand Down
2 changes: 1 addition & 1 deletion api/src/slices/agent/agent/dtos/agent.dto.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -22,7 +22,7 @@ export class AgentDto {
nullable: true,
type: String,
description:
"Human-readable reason accompanying status='failed' (e.g. \"startup did not produce a running agent within 5 minutes\", \"ImagePullBackOff\"). Null for all other statuses and for failures recorded before this field existed.",
'Human-readable reason accompanying status=\'failed\' (e.g. "startup did not produce a running agent within 5 minutes", "ImagePullBackOff"). Null for all other statuses and for failures recorded before this field existed.',
})
statusReason: string | null;

Expand Down
5 changes: 4 additions & 1 deletion api/src/slices/agent/agent/dtos/agentMetrics.dto.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -4,7 +4,10 @@ export class AgentPodMetricsDto {
@ApiProperty({ example: 234, description: 'Current CPU usage in millicores' })
cpuMilli: number;

@ApiProperty({ example: 471859200, description: 'Current memory usage in bytes' })
@ApiProperty({
example: 471859200,
description: 'Current memory usage in bytes',
})
memBytes: number;

@ApiProperty({ example: 2000, description: 'CPU limit in millicores' })
Expand Down
5 changes: 1 addition & 4 deletions api/src/slices/agent/agent/dtos/index.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -9,7 +9,4 @@ export {
AgentNodeMetricsDto,
AgentPodMetricsDto,
} from './agentMetrics.dto';
export {
ClusterCapacityDto,
NodeCapacityDto,
} from './clusterCapacity.dto';
export { ClusterCapacityDto, NodeCapacityDto } from './clusterCapacity.dto';
Loading
Loading