Deploy multi-file Hub workflows (#3128)
* feat(hub): deploy canonical workflow bundles Keep CLI deployment aligned with Hub's authored bundle boundary so validation and activation receive the same deterministic source files. Public documentation now teaches that boundary as the only configuration format. * test(hub): assert validation diagnostics directly * refactor(hub): deepen HTTP client module * feat(hub): validate named agent configurations * docs(hub): align Claude mode example
This commit is contained in:
@@ -0,0 +1,69 @@
|
||||
import type { ProviderOptions } from "@getpaseo/protocol/agent-types";
|
||||
import type { HubExecutionAgentValidationIssue } from "@getpaseo/protocol/messages";
|
||||
|
||||
import type { ProviderSnapshotEntry } from "./agent-sdk-types.js";
|
||||
import { filterSelectableAgentModels } from "./agent-sdk-types.js";
|
||||
import { ProviderOptionsValidationError } from "./provider-options.js";
|
||||
|
||||
export interface AgentConfigurationValidationInput {
|
||||
provider: string;
|
||||
model?: string;
|
||||
modeId?: string;
|
||||
thinkingOptionId?: string;
|
||||
providerOptions?: ProviderOptions;
|
||||
}
|
||||
|
||||
interface AgentConfigurationValidationContext {
|
||||
input: AgentConfigurationValidationInput;
|
||||
provider: ProviderSnapshotEntry;
|
||||
validateOptions(options: ProviderOptions | undefined): ProviderOptions | undefined;
|
||||
}
|
||||
|
||||
export function validateAgentConfigurationAgainstProvider({
|
||||
input,
|
||||
provider,
|
||||
validateOptions,
|
||||
}: AgentConfigurationValidationContext): HubExecutionAgentValidationIssue[] {
|
||||
const issues: HubExecutionAgentValidationIssue[] = [];
|
||||
const models = filterSelectableAgentModels(provider.models);
|
||||
const requestedModel = input.model;
|
||||
const selectedModel = requestedModel
|
||||
? models.find((model) => model.id === requestedModel || model.aliases?.includes(requestedModel))
|
||||
: (models.find((model) => model.isDefault) ?? models[0]);
|
||||
|
||||
if (requestedModel && !selectedModel) {
|
||||
issues.push({
|
||||
path: ["model"],
|
||||
message: `Model '${requestedModel}' is not available for provider '${input.provider}'`,
|
||||
});
|
||||
}
|
||||
if (input.modeId && !provider.modes?.some((mode) => mode.id === input.modeId)) {
|
||||
issues.push({
|
||||
path: ["modeId"],
|
||||
message: `Mode '${input.modeId}' is not available for provider '${input.provider}'`,
|
||||
});
|
||||
}
|
||||
if (
|
||||
input.thinkingOptionId &&
|
||||
!selectedModel?.thinkingOptions?.some((option) => option.id === input.thinkingOptionId)
|
||||
) {
|
||||
issues.push({
|
||||
path: ["thinkingOptionId"],
|
||||
message: `Thinking option '${input.thinkingOptionId}' is not available for provider '${input.provider}'`,
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
validateOptions(input.providerOptions);
|
||||
} catch (error) {
|
||||
if (!(error instanceof ProviderOptionsValidationError)) throw error;
|
||||
issues.push(
|
||||
...error.issues.map((issue) => ({
|
||||
path: ["providerOptions", ...issue.path],
|
||||
message: issue.message,
|
||||
})),
|
||||
);
|
||||
}
|
||||
|
||||
return issues;
|
||||
}
|
||||
@@ -69,6 +69,84 @@ async function withEnv(key: string, value: string, run: () => Promise<void>): Pr
|
||||
}
|
||||
|
||||
describe("ProviderSnapshotManager public surface", () => {
|
||||
test("validates complete Hub agent configurations through the current provider contract", async () => {
|
||||
const manager = new ProviderSnapshotManager({
|
||||
logger: createTestLogger(),
|
||||
extraClients: {
|
||||
codex: createExtraClient("codex", {
|
||||
isAvailable: async () => true,
|
||||
fetchCatalog: async () => ({
|
||||
models: [
|
||||
{
|
||||
provider: "codex",
|
||||
id: "gpt-5.5",
|
||||
aliases: ["gpt-latest"],
|
||||
label: "GPT 5.5",
|
||||
thinkingOptions: [{ id: "xhigh", label: "Extra high" }],
|
||||
},
|
||||
],
|
||||
modes: [{ id: "auto-review", label: "Auto review" }],
|
||||
}),
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
await expect(
|
||||
manager.validateAgentConfiguration({
|
||||
provider: "codex",
|
||||
model: "gpt-latest",
|
||||
modeId: "auto-review",
|
||||
thinkingOptionId: "xhigh",
|
||||
providerOptions: {
|
||||
sandbox_workspace_write: {
|
||||
writable_roots: ["/var/cache/npm"],
|
||||
network_access: false,
|
||||
},
|
||||
},
|
||||
}),
|
||||
).resolves.toEqual([]);
|
||||
|
||||
await expect(
|
||||
manager.validateAgentConfiguration({
|
||||
provider: "codex",
|
||||
model: "missing",
|
||||
modeId: "missing",
|
||||
thinkingOptionId: "missing",
|
||||
providerOptions: {
|
||||
sandbox_workspace_write: { network_access: "sometimes" },
|
||||
},
|
||||
}),
|
||||
).resolves.toEqual([
|
||||
{ path: ["model"], message: "Model 'missing' is not available for provider 'codex'" },
|
||||
{ path: ["modeId"], message: "Mode 'missing' is not available for provider 'codex'" },
|
||||
{
|
||||
path: ["thinkingOptionId"],
|
||||
message: "Thinking option 'missing' is not available for provider 'codex'",
|
||||
},
|
||||
{
|
||||
path: ["providerOptions", "sandbox_workspace_write", "network_access"],
|
||||
message: "Invalid input: expected boolean, received string",
|
||||
},
|
||||
]);
|
||||
} finally {
|
||||
manager.destroy();
|
||||
}
|
||||
});
|
||||
|
||||
test("reports an unavailable Hub agent provider at the authored provider field", async () => {
|
||||
const manager = new ProviderSnapshotManager({ logger: createTestLogger() });
|
||||
try {
|
||||
await expect(
|
||||
manager.validateAgentConfiguration({ provider: "not-installed" }),
|
||||
).resolves.toEqual([
|
||||
{ path: ["provider"], message: "Provider 'not-installed' is not configured" },
|
||||
]);
|
||||
} finally {
|
||||
manager.destroy();
|
||||
}
|
||||
});
|
||||
|
||||
test("listRegisteredProviderIds includes the built-in providers", () => {
|
||||
const manager = new ProviderSnapshotManager({ logger: createTestLogger() });
|
||||
try {
|
||||
|
||||
@@ -35,6 +35,11 @@ import {
|
||||
formatProviderDiagnosticError,
|
||||
} from "./providers/diagnostic-utils.js";
|
||||
import type { MutableDaemonConfig } from "../daemon-config-store.js";
|
||||
import type { HubExecutionAgentValidationIssue } from "@getpaseo/protocol/messages";
|
||||
import {
|
||||
type AgentConfigurationValidationInput,
|
||||
validateAgentConfigurationAgainstProvider,
|
||||
} from "./agent-configuration-validator.js";
|
||||
|
||||
const DEFAULT_REFRESH_TIMEOUT_MS = 60_000;
|
||||
const DEFAULT_DIAGNOSTIC_TIMEOUT_MS = 120_000;
|
||||
@@ -325,6 +330,45 @@ export class ProviderSnapshotManager {
|
||||
return entry;
|
||||
}
|
||||
|
||||
async validateAgentConfiguration(
|
||||
input: AgentConfigurationValidationInput,
|
||||
): Promise<HubExecutionAgentValidationIssue[]> {
|
||||
if (!this.hasProvider(input.provider)) {
|
||||
return [
|
||||
{
|
||||
path: ["provider"],
|
||||
message: `Provider '${input.provider}' is not configured`,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
const provider = await this.getProvider({
|
||||
provider: input.provider,
|
||||
wait: true,
|
||||
});
|
||||
if (!provider.enabled) {
|
||||
return [{ path: ["provider"], message: `Provider '${input.provider}' is disabled` }];
|
||||
}
|
||||
if (provider.status !== "ready") {
|
||||
return [
|
||||
{
|
||||
path: ["provider"],
|
||||
message:
|
||||
provider.status === "error" && provider.error
|
||||
? provider.error
|
||||
: `Provider '${input.provider}' is not available`,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
const definition = this.requireProvider(input.provider);
|
||||
return validateAgentConfigurationAgainstProvider({
|
||||
input,
|
||||
provider,
|
||||
validateOptions: definition.validateOptions,
|
||||
});
|
||||
}
|
||||
|
||||
async listModels(input: ProviderSnapshotProviderOptions): Promise<AgentModelDefinition[]> {
|
||||
const entry = await this.getReadyProvider(input);
|
||||
return filterSelectableAgentModels(entry.models);
|
||||
|
||||
@@ -2,6 +2,7 @@ import { describe, expect, test } from "vitest";
|
||||
import type {
|
||||
AgentSnapshotPayload,
|
||||
HubExecutionAgentCreateRequest,
|
||||
HubExecutionAgentValidateRequest,
|
||||
SessionOutboundMessage,
|
||||
} from "@getpaseo/protocol/messages";
|
||||
|
||||
@@ -81,11 +82,44 @@ class RejectingHubExecutionAgents implements HubExecutionAgents {
|
||||
}
|
||||
|
||||
describe("HubExecutionController", () => {
|
||||
test("validates a named agent through the daemon provider registry", async () => {
|
||||
const messages: SessionOutboundMessage[] = [];
|
||||
const validateAgentConfiguration = async (
|
||||
message: Omit<HubExecutionAgentValidateRequest, "type" | "requestId">,
|
||||
) =>
|
||||
message.model === "missing" ? [{ path: ["model"], message: "Model is unavailable" }] : [];
|
||||
const controller = new HubExecutionController({
|
||||
agents: new ControlledHubExecutionAgents(),
|
||||
validateAgentConfiguration,
|
||||
send: (message) => messages.push(message),
|
||||
});
|
||||
|
||||
await controller.validateAgent({
|
||||
type: "hub.execution.agent.validate.request",
|
||||
requestId: "validate-agent",
|
||||
provider: "codex",
|
||||
model: "missing",
|
||||
});
|
||||
|
||||
expect(messages).toEqual([
|
||||
{
|
||||
type: "hub.execution.agent.validate.response",
|
||||
payload: {
|
||||
requestId: "validate-agent",
|
||||
valid: false,
|
||||
issues: [{ path: ["model"], message: "Model is unavailable" }],
|
||||
error: null,
|
||||
},
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test("cleanup fences in-flight creates before the dead session can receive a response", async () => {
|
||||
const agents = new ControlledHubExecutionAgents();
|
||||
const messages: SessionOutboundMessage[] = [];
|
||||
const controller = new HubExecutionController({
|
||||
agents,
|
||||
validateAgentConfiguration: async () => [],
|
||||
send: (message) => messages.push(message),
|
||||
});
|
||||
|
||||
@@ -111,6 +145,7 @@ describe("HubExecutionController", () => {
|
||||
const messages: SessionOutboundMessage[] = [];
|
||||
const controller = new HubExecutionController({
|
||||
agents,
|
||||
validateAgentConfiguration: async () => [],
|
||||
send: (message) => messages.push(message),
|
||||
});
|
||||
|
||||
@@ -165,6 +200,7 @@ describe("HubExecutionController", () => {
|
||||
const messages: SessionOutboundMessage[] = [];
|
||||
const controller = new HubExecutionController({
|
||||
agents: new RejectingHubExecutionAgents(error),
|
||||
validateAgentConfiguration: async () => [],
|
||||
send: (message) => messages.push(message),
|
||||
});
|
||||
|
||||
|
||||
@@ -2,6 +2,8 @@ import { isAbsolute } from "node:path";
|
||||
import type {
|
||||
HubExecutionAgentCreateError,
|
||||
HubExecutionAgentCreateRequest,
|
||||
HubExecutionAgentValidateRequest,
|
||||
HubExecutionAgentValidationIssue,
|
||||
HubExecutionControlRequest,
|
||||
SessionOutboundMessage,
|
||||
} from "@getpaseo/protocol/messages";
|
||||
@@ -14,20 +16,26 @@ import type { HubExecutionAgents, OwnedAgentEvent } from "./daemon-executions.js
|
||||
|
||||
interface HubExecutionControllerOptions {
|
||||
agents: HubExecutionAgents;
|
||||
validateAgentConfiguration: (
|
||||
input: Omit<HubExecutionAgentValidateRequest, "type" | "requestId">,
|
||||
) => Promise<HubExecutionAgentValidationIssue[]>;
|
||||
send: (message: SessionOutboundMessage) => void;
|
||||
}
|
||||
|
||||
export class HubExecutionController {
|
||||
private readonly agents: HubExecutionAgents;
|
||||
private readonly send: (message: SessionOutboundMessage) => void;
|
||||
private readonly validateAgentConfiguration: HubExecutionControllerOptions["validateAgentConfiguration"];
|
||||
private readonly unsubscribe: () => void;
|
||||
private readonly pendingCreates = new Set<Promise<void>>();
|
||||
private readonly pendingControls = new Set<Promise<void>>();
|
||||
private readonly pendingValidations = new Set<Promise<void>>();
|
||||
private cleanupPromise: Promise<void> | null = null;
|
||||
private closed = false;
|
||||
|
||||
constructor(options: HubExecutionControllerOptions) {
|
||||
this.agents = options.agents;
|
||||
this.validateAgentConfiguration = options.validateAgentConfiguration;
|
||||
this.send = options.send;
|
||||
this.unsubscribe = this.agents.subscribe((event) => this.sendOwnedEvent(event));
|
||||
}
|
||||
@@ -40,7 +48,50 @@ export class HubExecutionController {
|
||||
private async cleanupOnce(): Promise<void> {
|
||||
this.closed = true;
|
||||
this.unsubscribe();
|
||||
await Promise.allSettled([...this.pendingCreates, ...this.pendingControls]);
|
||||
await Promise.allSettled([
|
||||
...this.pendingCreates,
|
||||
...this.pendingControls,
|
||||
...this.pendingValidations,
|
||||
]);
|
||||
}
|
||||
|
||||
async validateAgent(message: HubExecutionAgentValidateRequest): Promise<void> {
|
||||
if (this.closed) return;
|
||||
const validation = this.validateAgentWithResponse(message);
|
||||
this.pendingValidations.add(validation);
|
||||
try {
|
||||
await validation;
|
||||
} finally {
|
||||
this.pendingValidations.delete(validation);
|
||||
}
|
||||
}
|
||||
|
||||
private async validateAgentWithResponse(
|
||||
message: HubExecutionAgentValidateRequest,
|
||||
): Promise<void> {
|
||||
let issues: HubExecutionAgentValidationIssue[] = [];
|
||||
let error: string | null = null;
|
||||
try {
|
||||
issues = await this.validateAgentConfiguration({
|
||||
provider: message.provider,
|
||||
model: message.model,
|
||||
modeId: message.modeId,
|
||||
thinkingOptionId: message.thinkingOptionId,
|
||||
providerOptions: message.providerOptions,
|
||||
});
|
||||
} catch (validationError) {
|
||||
error = validationError instanceof Error ? validationError.message : String(validationError);
|
||||
}
|
||||
if (this.closed) return;
|
||||
this.send({
|
||||
type: "hub.execution.agent.validate.response",
|
||||
payload: {
|
||||
requestId: message.requestId,
|
||||
valid: error === null && issues.length === 0,
|
||||
issues,
|
||||
error,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async controlExecution(message: HubExecutionControlRequest): Promise<void> {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { createHubCommand } from "../../../../../cli/src/commands/hub/index.js";
|
||||
import { HubHttpClient } from "../../../../../cli/src/commands/hub/client.js";
|
||||
import { HubHttpClient } from "../../../../../cli/src/commands/hub/hub-client/index.js";
|
||||
|
||||
const CONTRACT_API_KEY_PREFIX = "hub-contract-api-key:";
|
||||
|
||||
|
||||
@@ -310,6 +310,7 @@ interface SessionForTestOptions {
|
||||
getDaemonTcpPort?: () => number | null;
|
||||
getDaemonTcpHost?: () => string | null;
|
||||
providerSnapshotManager?: ProviderSnapshotManager;
|
||||
hubExecutionAgents?: SessionOptions["hubExecutionAgents"];
|
||||
stt?: SessionOptions["stt"];
|
||||
voice?: SessionOptions["voice"];
|
||||
paseoHome?: string;
|
||||
@@ -412,6 +413,7 @@ function createSessionForTest(options: SessionForTestOptions = {}): Session {
|
||||
terminalManager: options.terminalManager ?? null,
|
||||
providerSnapshotManager:
|
||||
options.providerSnapshotManager ?? createProviderSnapshotManagerStub().manager,
|
||||
hubExecutionAgents: options.hubExecutionAgents,
|
||||
serviceProxy: options.serviceProxy,
|
||||
scriptRuntimeStore: options.scriptRuntimeStore,
|
||||
getDaemonTcpPort: options.getDaemonTcpPort,
|
||||
@@ -426,6 +428,44 @@ function createSessionForTest(options: SessionForTestOptions = {}): Session {
|
||||
}
|
||||
|
||||
describe("session authorization scopes", () => {
|
||||
test("routes named-agent validation through the session source", async () => {
|
||||
const messages: SessionOutboundMessage[] = [];
|
||||
const providers = createProviderSnapshotManagerStub();
|
||||
providers.validateAgentConfiguration.mockResolvedValue([
|
||||
{ path: ["model"], message: "Model is unavailable" },
|
||||
]);
|
||||
const session = createSessionForTest({
|
||||
messages,
|
||||
providerSnapshotManager: providers.manager,
|
||||
hubExecutionAgents: {
|
||||
create: vi.fn(),
|
||||
control: vi.fn(),
|
||||
subscribe: vi.fn(() => () => undefined),
|
||||
invalidateAuthority: vi.fn(),
|
||||
},
|
||||
});
|
||||
|
||||
await session.handleMessage({
|
||||
type: "hub.execution.agent.validate.request",
|
||||
requestId: "validate-agent",
|
||||
provider: "codex",
|
||||
model: "missing",
|
||||
});
|
||||
|
||||
expect(providers.validateAgentConfiguration).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ provider: "codex", model: "missing" }),
|
||||
);
|
||||
expect(messages).toContainEqual({
|
||||
type: "hub.execution.agent.validate.response",
|
||||
payload: {
|
||||
requestId: "validate-agent",
|
||||
valid: false,
|
||||
issues: [{ path: ["model"], message: "Model is unavailable" }],
|
||||
error: null,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test("rejects an RPC outside an exact grant with the generic RPC error", async () => {
|
||||
const messages: SessionOutboundMessage[] = [];
|
||||
const session = createSessionForTest({
|
||||
|
||||
@@ -918,6 +918,8 @@ export class Session {
|
||||
this.hubExecutionController = options.hubExecutionAgents
|
||||
? new HubExecutionController({
|
||||
agents: options.hubExecutionAgents,
|
||||
validateAgentConfiguration: (input) =>
|
||||
providerSnapshotManager.validateAgentConfiguration(input),
|
||||
send: (message) => this.emit(message),
|
||||
})
|
||||
: null;
|
||||
@@ -1968,6 +1970,9 @@ export class Session {
|
||||
if (msg.type === "hub.execution.agent.create.request") {
|
||||
return this.hubExecutionController?.createAgent(msg);
|
||||
}
|
||||
if (msg.type === "hub.execution.agent.validate.request") {
|
||||
return this.hubExecutionController?.validateAgent(msg);
|
||||
}
|
||||
if (msg.type === "hub.execution.control.request") {
|
||||
return this.hubExecutionController?.controlExecution(msg);
|
||||
}
|
||||
|
||||
@@ -15,7 +15,10 @@ import type {
|
||||
} from "../agent/provider-snapshot-manager.js";
|
||||
import { ProviderSnapshotManager } from "../agent/provider-snapshot-manager.js";
|
||||
import type { SessionOptions } from "../session.js";
|
||||
import type { SessionOutboundMessage } from "@getpaseo/protocol/messages";
|
||||
import type {
|
||||
HubExecutionAgentValidationIssue,
|
||||
SessionOutboundMessage,
|
||||
} from "@getpaseo/protocol/messages";
|
||||
import { asInternals, createStub } from "./class-mocks.js";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -155,6 +158,9 @@ export interface ProviderSnapshotManagerSpies {
|
||||
getAgentManagerProviderState: ReturnType<typeof vi.fn<[], AgentManagerProviderState>>;
|
||||
listProviders: ReturnType<typeof vi.fn<[unknown], Promise<ProviderSnapshotEntry[]>>>;
|
||||
getProvider: ReturnType<typeof vi.fn<[unknown], Promise<ProviderSnapshotEntry>>>;
|
||||
validateAgentConfiguration: ReturnType<
|
||||
typeof vi.fn<[unknown], Promise<HubExecutionAgentValidationIssue[]>>
|
||||
>;
|
||||
listModels: ReturnType<typeof vi.fn<[unknown], Promise<AgentModelDefinition[]>>>;
|
||||
listModes: ReturnType<typeof vi.fn<[unknown], Promise<AgentMode[]>>>;
|
||||
resolveCreateConfig: ReturnType<typeof vi.fn<[unknown], Promise<ResolvedProviderCreateConfig>>>;
|
||||
@@ -190,6 +196,9 @@ export function createProviderSnapshotManagerStub(): {
|
||||
const getProvider = vi.fn<[unknown], Promise<ProviderSnapshotEntry>>(async () => {
|
||||
throw new Error("createProviderSnapshotManagerStub: getProvider not stubbed");
|
||||
});
|
||||
const validateAgentConfiguration = vi.fn<[unknown], Promise<HubExecutionAgentValidationIssue[]>>(
|
||||
async () => [],
|
||||
);
|
||||
const listModels = vi.fn<[unknown], Promise<AgentModelDefinition[]>>(async () => []);
|
||||
const listModes = vi.fn<[unknown], Promise<AgentMode[]>>(async () => []);
|
||||
const resolveCreateConfig = vi.fn<[unknown], Promise<ResolvedProviderCreateConfig>>(async () => ({
|
||||
@@ -218,6 +227,7 @@ export function createProviderSnapshotManagerStub(): {
|
||||
getAgentManagerProviderState,
|
||||
listProviders,
|
||||
getProvider,
|
||||
validateAgentConfiguration,
|
||||
listModels,
|
||||
listModes,
|
||||
resolveCreateConfig,
|
||||
@@ -243,6 +253,7 @@ export function createProviderSnapshotManagerStub(): {
|
||||
getAgentManagerProviderState,
|
||||
listProviders,
|
||||
getProvider,
|
||||
validateAgentConfiguration,
|
||||
listModels,
|
||||
listModes,
|
||||
resolveCreateConfig,
|
||||
|
||||
Reference in New Issue
Block a user