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
12 changes: 12 additions & 0 deletions .github/workflows/publish-cli.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,18 @@ jobs:
working-directory: cli
run: bun run build

- name: Verify built binary reports the package version
if: steps.gate.outputs.publish == 'true'
working-directory: cli
run: |
EXPECTED="${{ steps.versions.outputs.package }}"
ACTUAL=$(node dist/ranch.mjs --version)
if [ "$ACTUAL" != "$EXPECTED" ]; then
echo "::error::Built binary reports v$ACTUAL but package.json is v$EXPECTED — refusing to publish a stale dist."
exit 1
fi
echo "Built binary reports v$ACTUAL ✓"

- name: Pack (dry run, sanity check)
if: steps.gate.outputs.publish == 'true'
working-directory: cli
Expand Down
51 changes: 51 additions & 0 deletions admin/slices/setup/api/data/repositories/api/sdk.gen.ts
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,8 @@ import type {
AgentControllerDemoteAdminData,
AgentControllerPromoteAdminData,
AgentControllerRestartData,
AgentControllerStopData,
AgentControllerStartData,
RestartByTemplateData,
FileControllerListData,
FileControllerReadData,
Expand All @@ -98,6 +100,7 @@ import type {
ResetBridleTranscriptResponse,
GetBridleTranscriptData,
GetBridleTranscriptResponse,
ArchiveBridleTranscriptData,
SkillControllerFindAllData,
SkillControllerCreateData,
SkillControllerListSourcesData,
Expand Down Expand Up @@ -1433,6 +1436,38 @@ export class AgentsService {
});
}

/**
* Stop an agent without deleting it: cancels its workflow and deletes its pod to free cluster CPU/memory, then marks it `stopped`. Use this to free a slot so another agent can start. Bring it back with POST :id/start. Admin or Owner.
*/
public static agentControllerStop<ThrowOnError extends boolean = false>(
options: Options<AgentControllerStopData, ThrowOnError>,
) {
return (options.client ?? _heyApiClient).post<
unknown,
unknown,
ThrowOnError
>({
url: "/agents/{id}/stop",
...options,
});
}

/**
* Start a stopped agent: deploys a fresh pod and reattaches the runtime. Inverse of POST :id/stop. Admin or Owner.
*/
public static agentControllerStart<ThrowOnError extends boolean = false>(
options: Options<AgentControllerStartData, ThrowOnError>,
) {
return (options.client ?? _heyApiClient).post<
unknown,
unknown,
ThrowOnError
>({
url: "/agents/{id}/start",
...options,
});
}

/**
* Restart every agent that uses this template. Pulls latest template-owned files into each agent and redeploys, preserving runtime state. Concurrency capped at 5 to avoid overwhelming the cluster. Admin or Owner.
*/
Expand Down Expand Up @@ -1692,6 +1727,22 @@ export class BridleService {
...options,
});
}

/**
* Archive the persisted chat transcript for an agent/channel — the live JSONL is moved to a timestamped sibling (`bridle:<channel>.<iso-ts>.archived.jsonl`) and the live slot starts empty. Used by the embed's "New chat" action when the visitor wants a clean slate but we still want the prior conversation for admin/audit. No-op (returns `{}`) when there's nothing to archive.
*/
public static archiveBridleTranscript<ThrowOnError extends boolean = false>(
options: Options<ArchiveBridleTranscriptData, ThrowOnError>,
) {
return (options.client ?? _heyApiClient).post<
unknown,
unknown,
ThrowOnError
>({
url: "/api/agent/{agentId}/transcript/archive",
...options,
});
}
}

export class SkillsService {
Expand Down
44 changes: 44 additions & 0 deletions admin/slices/setup/api/data/repositories/api/types.gen.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1965,6 +1965,32 @@ export type AgentControllerRestartResponses = {
201: unknown;
};

export type AgentControllerStopData = {
body?: never;
path: {
id: string;
};
query?: never;
url: "/agents/{id}/stop";
};

export type AgentControllerStopResponses = {
201: unknown;
};

export type AgentControllerStartData = {
body?: never;
path: {
id: string;
};
query?: never;
url: "/agents/{id}/start";
};

export type AgentControllerStartResponses = {
201: unknown;
};

export type RestartByTemplateData = {
body?: never;
path: {
Expand Down Expand Up @@ -2175,6 +2201,24 @@ export type GetBridleTranscriptResponses = {
export type GetBridleTranscriptResponse =
GetBridleTranscriptResponses[keyof GetBridleTranscriptResponses];

export type ArchiveBridleTranscriptData = {
body?: never;
path: {
agentId: string;
};
query?: {
/**
* Session channel — defaults to "admin".
*/
channel?: string;
};
url: "/api/agent/{agentId}/transcript/archive";
};

export type ArchiveBridleTranscriptResponses = {
200: unknown;
};

export type SkillControllerFindAllData = {
body?: never;
path?: never;
Expand Down
4 changes: 2 additions & 2 deletions api/src/slices/bridle/bridle.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -80,7 +80,7 @@ export class BridleController {

return new Promise((resolve) => {
const timeout = setTimeout(() => {
this.hub.unregisterClient(clientId);
this.hub.unregisterClient(clientId, agentId);
resolve({
text: chunks.join('') || 'Timeout: no response from agent',
messageId: '',
Expand All @@ -95,7 +95,7 @@ export class BridleController {
const event = data as Record<string, unknown>;
if (event.type === 'message' || event.type === 'stream_end') {
clearTimeout(timeout);
this.hub.unregisterClient(clientId);
this.hub.unregisterClient(clientId, agentId);
resolve({
text: event.text ?? chunks.join(''),
messageId: event.messageId,
Expand Down
32 changes: 21 additions & 11 deletions api/src/slices/bridle/data/bridle.gateway.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,9 +36,18 @@ export class BridleGateway extends IBridleGateway {
/** Agent connections: agentId → send function */
private agents = new Map<string, (data: unknown) => void>();

/** Browser clients: clientId → { agentId, send } */
/**
* Browser clients keyed by `${clientId}\u0000${agentId}`. Keying by the pair
* (not clientId alone) lets ONE user hold several concurrent conversations —
* e.g. a multi-slot dashboard chatting with N agents on N sockets — without
* later sockets overwriting earlier ones (they share clientId='admin'/sub).
*/
private clients = new Map<string, IBridleClientData>();

private clientKey(clientId: string, agentId: string): string {
return `${clientId}\u0000${agentId}`;
}

/** Pending sync requests awaiting agent ack: requestId → pending */
private pendingSyncs = new Map<string, IPendingSync>();

Expand Down Expand Up @@ -97,7 +106,8 @@ export class BridleGateway extends IBridleGateway {
isAdmin: boolean,
prompt?: string,
): void {
this.clients.set(clientId, {
this.clients.set(this.clientKey(clientId, agentId), {
clientId,
agentId,
send,
isAdmin,
Expand All @@ -108,10 +118,10 @@ export class BridleGateway extends IBridleGateway {
);
}

unregisterClient(clientId: string): void {
this.clients.delete(clientId);
unregisterClient(clientId: string, agentId: string): void {
this.clients.delete(this.clientKey(clientId, agentId));
this.logger.log(
`Browser client unregistered: ${clientId} (total: ${this.clients.size})`,
`Browser client unregistered: ${clientId} agentId=${agentId} (total: ${this.clients.size})`,
);
}

Expand All @@ -126,7 +136,7 @@ export class BridleGateway extends IBridleGateway {
this.logger.warn(
`Cannot send to agent — not connected (agentId=${agentId})`,
);
this.sendToClient(clientId, {
this.sendToClient(clientId, agentId, {
type: 'message',
text: 'Agent is not connected. Please try again later.',
parts: [
Expand All @@ -141,7 +151,7 @@ export class BridleGateway extends IBridleGateway {
return;
}

const client = this.clients.get(clientId);
const client = this.clients.get(this.clientKey(clientId, agentId));
agentSend({
type: 'message',
clientId,
Expand All @@ -152,8 +162,8 @@ export class BridleGateway extends IBridleGateway {
});
}

sendToClient(clientId: string, data: unknown): void {
const client = this.clients.get(clientId);
sendToClient(clientId: string, agentId: string, data: unknown): void {
const client = this.clients.get(this.clientKey(clientId, agentId));
if (client) {
client.send(data);
}
Expand All @@ -163,8 +173,8 @@ export class BridleGateway extends IBridleGateway {
const clientId = data.clientId;
if (!clientId) return;

const client = this.clients.get(clientId);
if (client && client.agentId === agentId) {
const client = this.clients.get(this.clientKey(clientId, agentId));
if (client) {
client.send(data);
}
}
Expand Down
8 changes: 4 additions & 4 deletions api/src/slices/bridle/domain/bridle.gateway.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,8 +30,8 @@ export abstract class IBridleGateway {
text: string,
parts: BridlePart[],
): void;
/** Send an event to a specific browser client */
abstract sendToClient(clientId: string, data: unknown): void;
/** Send an event to a specific browser client (scoped to clientId + agentId) */
abstract sendToClient(clientId: string, agentId: string, data: unknown): void;
/** Register a browser client for a specific agent */
abstract registerClient(
clientId: string,
Expand All @@ -42,8 +42,8 @@ export abstract class IBridleGateway {
* agent on every message in this session. */
prompt?: string,
): void;
/** Unregister a browser client */
abstract unregisterClient(clientId: string): void;
/** Unregister a browser client (scoped to clientId + agentId) */
abstract unregisterClient(clientId: string, agentId: string): void;
/** Register an agent connection for a specific agent */
abstract registerAgent(agentId: string, send: (data: unknown) => void): void;
/** Unregister an agent connection for a specific agent */
Expand Down
1 change: 1 addition & 0 deletions api/src/slices/bridle/domain/bridle.types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,7 @@ export interface IBridleAgentHealthData {

/** Registered client metadata */
export interface IBridleClientData {
clientId: string;
agentId: string;
send: (data: unknown) => void;
isAdmin: boolean;
Expand Down
9 changes: 6 additions & 3 deletions api/src/slices/bridle/handlers/bridleClientWs.handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -192,9 +192,12 @@ export class BridleClientWsHandler

handleDisconnect(client: Socket) {
const clientId = client.data?.clientId as string | undefined;
if (clientId) {
this.hub.unregisterClient(clientId);
this.logger.log(`Browser disconnected: clientId=${clientId}`);
const agentId = client.data?.agentId as string | undefined;
if (clientId && agentId) {
this.hub.unregisterClient(clientId, agentId);
this.logger.log(
`Browser disconnected: clientId=${clientId} agentId=${agentId}`,
);
}
}

Expand Down
10 changes: 9 additions & 1 deletion api/src/slices/workflow/data/agent-workflow.manifest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -220,8 +220,16 @@ function buildAgentPod(
// running browser_play / Chromium). Guaranteed (requests == limits
// at 2 CPU / 2Gi) made schedules fail on small Hetzner nodes —
// even one agent didn't fit alongside the platform pods.
//
// CPU request is 100m, not 500m: idle agents actually use ~10-20m,
// so 500m × ~14 agents reserved 100% of an 8-vCPU cx43 node while it
// ran at 8% — new agents stuck Pending on "Insufficient cpu" despite
// a near-idle node. The scheduler packs by requests, not usage, so
// an honest low floor is what lets agents fit. Bursting is unaffected
// (limits still come from i.cpu). Memory floor stays 512Mi — that's
// a real idle footprint and becomes the next ceiling (~28/node).
resources: {
requests: { cpu: '500m', memory: '512Mi' },
requests: { cpu: '100m', memory: '512Mi' },
limits: { cpu: i.cpu, memory: i.memory },
},
ports: [{ containerPort: 3000 }],
Expand Down
Loading
Loading