From 8d1d7a665537f6fafe9f62ab5bd38661e159eea2 Mon Sep 17 00:00:00 2001 From: Ricky Schema Cascade Date: Tue, 23 Jun 2026 23:52:52 +0200 Subject: [PATCH 1/2] feat(deploy): support optional integrations --- docs/plans/deploy-v1.md | 17 +- packages/deploy/src/deploy.test.ts | 134 ++++++++++++++ packages/deploy/src/deploy.ts | 164 +++++++++++++++--- packages/deploy/src/preflight.ts | 10 ++ .../persona-kit/schemas/persona.schema.json | 8 +- packages/persona-kit/src/define.ts | 2 + packages/persona-kit/src/emit-schema.test.ts | 14 ++ packages/persona-kit/src/parse.test.ts | 31 ++++ packages/persona-kit/src/parse.ts | 24 ++- packages/persona-kit/src/types.ts | 8 + 10 files changed, 377 insertions(+), 35 deletions(-) diff --git a/docs/plans/deploy-v1.md b/docs/plans/deploy-v1.md index 4b689ff4..3e5f6e22 100644 --- a/docs/plans/deploy-v1.md +++ b/docs/plans/deploy-v1.md @@ -84,25 +84,20 @@ All new fields are optional. A persona that does not set any of them continues t ```jsonc "integrations": { "github": { - "scope": { "repo": "AgentWorkforce/workforce" }, // optional; provider-specific filter - "triggers": [ - { "on": "pull_request.opened" }, - { "on": "issue_comment.created", "match": "@mention" }, // match is a sugar lint, see §3.7 - { "on": "pull_request_review_comment.created" }, - { "on": "check_run.completed", "where": "conclusion=failure" } - ] + "scope": { "repo": "AgentWorkforce/workforce" } // optional; provider-specific filter }, - "linear": { "triggers": [{ "on": "issue.created" }] }, - "slack": { "triggers": [{ "on": "app_mention" }] }, - "notion": { "scope": { "database": "..." }, "triggers": [{ "on": "page.updated" }] } + "linear": {}, + "slack": { "optional": true, "enabledByInput": "SLACK_CHANNEL" }, + "notion": { "scope": { "database": "..." } } } ``` Key choices: - **Key is the Relayfile provider slug.** `github`, `linear`, `slack`, `notion`, `jira`. The deploy step calls `RelayfileSetup.connectIntegration({ allowedIntegrations: [key] })` for any provider not yet connected to the user's workspace. -- **`triggers[]` is a flat list per provider** — multiple events from the same provider all fan into the same `onEvent`. The handler discriminates on `event.source` + `event.type`. +- **Triggers live on the agent spec, not the persona integration config.** The persona declares connection setup; `agent.ts` declares which provider events fire the deployed agent. - **`match` and `where` are sugars** — `match: "@mention"` is shorthand for "filter to events that mention the deployed agent." The deploy CLI lints them against a known set; unknown values warn but don't fail. We can always upgrade the runtime to enforce them later. - **`scope` is optional and provider-specific.** Validated by the deploy CLI against a small provider-schema map. For v1, supported keys are documented per provider in the examples. +- **Optional integrations are enabled by persona inputs.** Set `optional: true` and `enabledByInput: "SLACK_CHANNEL"` to skip connection and provider trigger registration unless that declared input resolves to a non-empty value. The act of stacking integrations is just declaring multiple keys. The act of linking them ("when GitHub fires, post to Slack") is code in `onEvent`. We considered a declarative `links:` block — see §11.4 for why we deferred it. diff --git a/packages/deploy/src/deploy.test.ts b/packages/deploy/src/deploy.test.ts index 34e79ae0..bbd92e6f 100644 --- a/packages/deploy/src/deploy.test.ts +++ b/packages/deploy/src/deploy.test.ts @@ -77,6 +77,16 @@ export default defineAgent({ `; } +const SLACK_TELEGRAM_AGENT_SRC = `import { defineAgent } from '@agentworkforce/runtime'; +export default defineAgent({ + triggers: { + slack: [{ on: 'message.created' }], + telegram: [{ on: 'message.created' }] + }, + handler: async () => {} +}); +`; + async function withTempPersona( persona: Record, agentSource = SCHEDULE_AGENT_SRC @@ -365,6 +375,24 @@ test('preflightPersona refuses when the agent triggers a provider the persona do } }); +test('preflightPersona refuses optional integrations enabled by undeclared inputs', async () => { + const { personaPath, cleanup } = await withTempPersona( + basePersonaJson({ + integrations: { + slack: { optional: true, enabledByInput: 'SLACK_CHANNEL' } + } + }) + ); + try { + await assert.rejects( + preflightPersona(personaPath), + /integration "slack" is enabled by input "SLACK_CHANNEL".*persona\.inputs does not declare SLACK_CHANNEL/ + ); + } finally { + await cleanup(); + } +}); + test('preflightPersona refuses when onEvent file is missing', async () => { const { personaPath, cleanup } = await withTempPersona(basePersonaJson({ onEvent: './does-not-exist.ts' })); try { @@ -643,6 +671,112 @@ test('deploy connects each missing persona integration before launch', async () } }); +test('deploy activates optional integrations from supplied persona inputs', async () => { + const { personaPath, cleanup } = await withTempPersona( + basePersonaJson({ + integrations: { + slack: { optional: true, enabledByInput: 'SLACK_CHANNEL' }, + telegram: { optional: true, enabledByInput: 'TELEGRAM_CHAT' } + }, + inputs: { + SLACK_CHANNEL: { description: 'Slack channel', optional: true }, + TELEGRAM_CHAT: { description: 'Telegram chat', optional: true } + } + }), + SLACK_TELEGRAM_AGENT_SRC + ); + const io = createBufferedIO(); + const checked: string[] = []; + const connected: string[] = []; + let stagedIntegrationKeys: string[] = []; + let launchedTriggerKeys: string[] = []; + const workspaceAuth: WorkspaceAuth = { + async resolveWorkspace() { + return { workspace: 'ws-test', token: 'tok' }; + } + }; + const integrations: IntegrationConnectResolver = { + async isConnected({ provider }) { + checked.push(provider); + return false; + }, + async connect({ provider }) { + connected.push(provider); + return { connectionId: `conn-${provider}` }; + } + }; + const bundle: BundleStager = { + async stage(input) { + stagedIntegrationKeys = Object.keys(input.persona.integrations ?? {}); + return successfulBundleStager().stage(input); + } + }; + const devLauncher: ModeLauncher = { + async launch(input: ModeLaunchInput) { + launchedTriggerKeys = Object.keys(input.agent.triggers ?? {}); + return { + id: 'pid-optional', + async stop() { + /* no-op */ + }, + done: Promise.resolve({ code: 0 }) + }; + } + }; + + try { + const result = await deploy( + { + personaPath, + mode: 'dev', + io, + inputs: { SLACK_CHANNEL: 'C1' } + }, + { + workspaceAuth, + integrations, + bundle, + modes: { dev: devLauncher } + } + ); + + assert.deepEqual(checked, ['slack']); + assert.deepEqual(connected, ['slack']); + assert.deepEqual(result.connectedIntegrations, ['slack']); + assert.deepEqual(stagedIntegrationKeys, ['slack']); + assert.deepEqual(launchedTriggerKeys, ['slack']); + assert.ok( + io.messages.find((m) => m.message.includes('integrations.telegram: optional; skipped')) + ); + } finally { + await cleanup(); + } +}); + +test('deploy refuses an agent whose optional integration inputs leave no active listeners', async () => { + const { personaPath, cleanup } = await withTempPersona( + basePersonaJson({ + integrations: { + slack: { optional: true, enabledByInput: 'SLACK_CHANNEL' }, + telegram: { optional: true, enabledByInput: 'TELEGRAM_CHAT' } + }, + inputs: { + SLACK_CHANNEL: { description: 'Slack channel', optional: true }, + TELEGRAM_CHAT: { description: 'Telegram chat', optional: true } + } + }), + SLACK_TELEGRAM_AGENT_SRC + ); + try { + await assert.rejects( + deploy({ personaPath, mode: 'dev', io: createBufferedIO() }), + /no active listeners after optional integrations were applied/ + ); + } finally { + await cleanup(); + } +}); + test('deploy dev mode injects runtime credentials for a detected writeback trigger without provider-token leakage', async () => { const providerTokenSentinel = 'WORKFORCE_PROVIDER_TOKEN_SHOULD_NOT_LEAK'; const integrations = { diff --git a/packages/deploy/src/deploy.ts b/packages/deploy/src/deploy.ts index 29185d48..d8b78df7 100644 --- a/packages/deploy/src/deploy.ts +++ b/packages/deploy/src/deploy.ts @@ -4,9 +4,11 @@ import { defaultApiUrl } from '@agent-relay/cloud'; import type { AgentSpec, IntegrationSource, + PersonaInputSpec, PersonaIntegrationTrigger, PersonaSpec } from '@agentworkforce/persona-kit'; +import { KNOWN_TRIGGER_PROVIDER_ALIASES as TRIGGER_PROVIDER_ALIASES } from '@agentworkforce/persona-kit'; import { bundleStager } from './bundle.js'; import { resolveCloudUrl } from './cloud-url.js'; import { @@ -42,6 +44,7 @@ import type { DeployIO, DeployMode, DeployOptions, + DeployPreflight, DeployResult, ModeLauncher } from './types.js'; @@ -129,12 +132,20 @@ export async function deploy(opts: DeployOptions, resolvers: DeployResolvers = { const mode: DeployMode = opts.mode ?? pickMode(opts); warnings.push(...preflight.warnings); for (const w of preflight.warnings) io.warn(w); + const activePreflight = selectActiveOptionalIntegrations(preflight, opts.inputs ?? {}); + for (const skipped of activePreflight.skippedOptionalIntegrations) { + io.info( + `integrations.${skipped.provider}: optional; skipped because input ${skipped.enabledByInput} is unset` + ); + } io.info( - `persona ${preflight.persona.id}: ${preflight.integrations.length} integration(s), ${preflight.schedules.length} schedule(s)` + `persona ${activePreflight.persona.id}: ${activePreflight.integrations.length} integration(s), ${activePreflight.schedules.length} schedule(s)` ); - validateSubscriptionSupport(preflight.persona, { + validateActiveAgent(activePreflight); + + validateSubscriptionSupport(activePreflight.persona, { mode, subscription: resolvers.subscription, ...(opts.harnessSource ? { harnessSource: opts.harnessSource } : {}) @@ -143,12 +154,12 @@ export async function deploy(opts: DeployOptions, resolvers: DeployResolvers = { if (opts.dryRun) { io.info('--dry-run: persona validated; exiting before any side effects'); return { - deploymentId: preflight.persona.id, + deploymentId: activePreflight.persona.id, mode, workspace: opts.workspace ?? '(dry-run)', bundleDir: '(dry-run)', connectedIntegrations: [], - schedules: preflight.schedules, + schedules: activePreflight.schedules, warnings }; } @@ -161,19 +172,19 @@ export async function deploy(opts: DeployOptions, resolvers: DeployResolvers = { await mkdir(bundleDir, { recursive: true }); const stager = resolvers.bundle ?? bundleStager; const bundle = await stager.stage({ - personaPath: preflight.personaPath, - persona: preflight.persona, + personaPath: activePreflight.personaPath, + persona: activePreflight.persona, outDir: bundleDir }); io.info(`bundle: staged to ${bundle.runnerPath} (${formatBytes(bundle.sizeBytes)})`); io.info(`--bundle-out: bundle ready at ${bundleDir}; skipping launch`); return { - deploymentId: preflight.persona.id, + deploymentId: activePreflight.persona.id, mode, workspace: opts.workspace ?? '(bundle-only)', bundleDir, connectedIntegrations: [], - schedules: preflight.schedules, + schedules: activePreflight.schedules, warnings }; } @@ -217,12 +228,12 @@ export async function deploy(opts: DeployOptions, resolvers: DeployResolvers = { }) : undefined); - if (preflight.persona.useSubscription && !subscription) { + if (activePreflight.persona.useSubscription && !subscription) { const result = await ensureCloudSubscriptionReady({ cloudUrl: normalizeCloudUrl(cloudUrl ?? defaultApiUrl()), workspaceId: workspace, token: activeToken, - persona: preflight.persona, + persona: activePreflight.persona, io, noPrompt: opts.noPrompt === true || opts.noConnect === true, ...(opts.harnessSource ? { harnessSource: opts.harnessSource } : {}), @@ -234,7 +245,7 @@ export async function deploy(opts: DeployOptions, resolvers: DeployResolvers = { } const connectedIntegrations = await connectAndCollectIntegrations({ - persona: preflight.persona, + persona: activePreflight.persona, workspace, noConnect: opts.noConnect === true, ...(opts.noPrompt ? { noPrompt: true } : {}), @@ -275,9 +286,9 @@ export async function deploy(opts: DeployOptions, resolvers: DeployResolvers = { workspaceToken: () => activeToken }) : undefined); - if (optionsResolver && opts.noPrompt !== true && (preflight.persona.inputs !== undefined)) { + if (optionsResolver && opts.noPrompt !== true && (activePreflight.persona.inputs !== undefined)) { resolvedInputs = await collectPickerInputs({ - persona: preflight.persona, + persona: activePreflight.persona, workspace, io, resolver: optionsResolver, @@ -288,21 +299,21 @@ export async function deploy(opts: DeployOptions, resolvers: DeployResolvers = { } const bundleDir = path.resolve( - path.join(preflight.personaDir, '.workforce', 'build', preflight.persona.id) + path.join(activePreflight.personaDir, '.workforce', 'build', activePreflight.persona.id) ); await mkdir(bundleDir, { recursive: true }); const stager = resolvers.bundle ?? bundleStager; const bundle = await stager.stage({ - personaPath: preflight.personaPath, - persona: preflight.persona, + personaPath: activePreflight.personaPath, + persona: activePreflight.persona, outDir: bundleDir }); io.info(`bundle: staged to ${bundle.runnerPath} (${formatBytes(bundle.sizeBytes)})`); const runtimeEnv = await resolveRuntimeCredentialEnv({ mode, - persona: preflight.persona, - agent: preflight.agent, + persona: activePreflight.persona, + agent: activePreflight.agent, workspace, workspaceToken: activeToken, ...(resolvedAuth.relayfileWorkspaceId ? { relayfileWorkspaceId: resolvedAuth.relayfileWorkspaceId } : {}), @@ -315,8 +326,8 @@ export async function deploy(opts: DeployOptions, resolvers: DeployResolvers = { io.info(`mode: ${mode}`); const launcher = resolveLauncher(mode, resolvers); const handle = await launcher.launch({ - persona: preflight.persona, - agent: preflight.agent, + persona: activePreflight.persona, + agent: activePreflight.agent, bundle, workspace, io, @@ -337,17 +348,126 @@ export async function deploy(opts: DeployOptions, resolvers: DeployResolvers = { io.info(`launched: ${mode}/${handle.id}`); return { - deploymentId: preflight.persona.id, + deploymentId: activePreflight.persona.id, mode, workspace, bundleDir, connectedIntegrations, - schedules: preflight.schedules, + schedules: activePreflight.schedules, runHandle: handle, warnings }; } +type ActiveDeployPreflight = DeployPreflight & { + skippedOptionalIntegrations: Array<{ provider: string; enabledByInput: string }>; +}; + +function selectActiveOptionalIntegrations( + preflight: DeployPreflight, + inputs: Record, + env: NodeJS.ProcessEnv = process.env +): ActiveDeployPreflight { + const integrations = preflight.persona.integrations ?? {}; + const entries = Object.entries(integrations); + if (!entries.some(([, cfg]) => cfg?.optional === true)) { + return { ...preflight, skippedOptionalIntegrations: [] }; + } + + const activeIntegrations: NonNullable = {}; + const inactiveTriggerProviders = new Set(); + const skippedOptionalIntegrations: ActiveDeployPreflight['skippedOptionalIntegrations'] = []; + + for (const [provider, cfg] of entries) { + if (cfg?.optional !== true) { + activeIntegrations[provider] = cfg; + continue; + } + + const enabledByInput = cfg.enabledByInput; + if (enabledByInput && personaInputIsSet(preflight.persona.inputs?.[enabledByInput], enabledByInput, inputs, env)) { + activeIntegrations[provider] = cfg; + continue; + } + + inactiveTriggerProviders.add(provider); + const alias = triggerProviderAlias(provider); + if (alias) inactiveTriggerProviders.add(alias); + skippedOptionalIntegrations.push({ + provider, + enabledByInput: enabledByInput ?? '(missing)' + }); + } + + const activeIntegrationKeys = Object.keys(activeIntegrations); + const persona: PersonaSpec = { + ...preflight.persona, + ...(activeIntegrationKeys.length > 0 + ? { integrations: activeIntegrations } + : { integrations: undefined }) + }; + const agent = filterInactiveTriggers(preflight.agent, inactiveTriggerProviders); + + return { + ...preflight, + persona, + agent, + integrations: activeIntegrationKeys, + skippedOptionalIntegrations + }; +} + +function personaInputIsSet( + spec: PersonaInputSpec | undefined, + inputName: string, + inputs: Record, + env: NodeJS.ProcessEnv +): boolean { + const explicit = inputs[inputName]; + if (typeof explicit === 'string') return explicit.trim().length > 0; + + const envName = spec?.env ?? inputName; + const envValue = env[envName]; + if (typeof envValue === 'string') return envValue.trim().length > 0; + + return typeof spec?.default === 'string' && spec.default.trim().length > 0; +} + +function filterInactiveTriggers( + agent: AgentSpec, + inactiveProviders: ReadonlySet +): AgentSpec { + if (!agent.triggers || inactiveProviders.size === 0) return agent; + + const nextTriggers: NonNullable = {}; + for (const [provider, triggers] of Object.entries(agent.triggers)) { + if (!inactiveProviders.has(provider)) { + nextTriggers[provider] = triggers; + } + } + + return { + ...agent, + ...(Object.keys(nextTriggers).length > 0 ? { triggers: nextTriggers } : { triggers: undefined }) + }; +} + +function triggerProviderAlias(provider: string): string | undefined { + return (TRIGGER_PROVIDER_ALIASES as Record)[provider]; +} + +function validateActiveAgent(preflight: ActiveDeployPreflight): void { + const hasTriggers = !!preflight.agent.triggers && Object.values(preflight.agent.triggers).some((t) => (t?.length ?? 0) > 0); + const hasSchedules = (preflight.agent.schedules?.length ?? 0) > 0; + const hasWatch = (preflight.agent.watch?.length ?? 0) > 0; + const hasDispatcherLaunch = preflight.agent.launchedBy === 'team-dispatcher'; + if (hasTriggers || hasSchedules || hasWatch || hasDispatcherLaunch) return; + + throw new Error( + `agent "${preflight.persona.id}" has no active listeners after optional integrations were applied` + ); +} + function resolveLauncher(mode: DeployMode, resolvers: DeployResolvers): ModeLauncher { const supplied = resolvers.modes?.[mode]; if (supplied) return supplied; diff --git a/packages/deploy/src/preflight.ts b/packages/deploy/src/preflight.ts index 2cfb5558..b290d86e 100644 --- a/packages/deploy/src/preflight.ts +++ b/packages/deploy/src/preflight.ts @@ -104,6 +104,16 @@ export async function preflightPersona(personaPath: string): Promise\" }` for notion).\n\nThis declares only *that the persona connects to the provider* and how the connection resolves — **not** which events fire it. Event triggers live on the agent ( {@link AgentSpec.triggers } ); the deploy CLI requires every provider in `agent.triggers` to also appear here so the connection is set up.\n\n`source` discriminates the cloud-side resolver between `user_integrations` and `workspace_integrations`; defaults to `{ kind: 'deployer_user' }` when omitted so existing personas keep their pre-discriminator behavior.\n\n`config` is a forward-compatible adapter passthrough. Persona-kit validates only that it is an object; provider adapters own the nested schema (for example GitHub materialization policy)." + "description": "Per-provider **connection** configuration for a RelayFile provider. The map key is the provider slug (`github`, `linear`, `slack`, `notion`, `jira`). `scope` is provider-specific filter metadata (e.g. `{ repo: \"org/repo\" }` for github, `{ database: \"\" }` for notion).\n\nThis declares only *that the persona connects to the provider* and how the connection resolves — **not** which events fire it. Event triggers live on the agent ( {@link AgentSpec.triggers } ); the deploy CLI requires every provider in `agent.triggers` to also appear here so the connection is set up.\n\n`source` discriminates the cloud-side resolver between `user_integrations` and `workspace_integrations`; defaults to `{ kind: 'deployer_user' }` when omitted so existing personas keep their pre-discriminator behavior.\n\n`config` is a forward-compatible adapter passthrough. Persona-kit validates only that it is an object; provider adapters own the nested schema (for example GitHub materialization policy).\n\nOptional integrations are deploy-time choices controlled by persona inputs: set `optional: true` and `enabledByInput: \"SLACK_CHANNEL\"` to include this provider only when that input resolves to a non-empty value. Inactive optional providers are skipped before deploy connects integrations or registers provider triggers." }, "IntegrationSource": { "anyOf": [ diff --git a/packages/persona-kit/src/define.ts b/packages/persona-kit/src/define.ts index ca6b9b95..c98aab32 100644 --- a/packages/persona-kit/src/define.ts +++ b/packages/persona-kit/src/define.ts @@ -182,6 +182,8 @@ export interface TypedIntegrationConfig

{ source?: IntegrationSource; scope?: TypedScopeMap

; config?: AdapterConfigFor

; + optional?: boolean; + enabledByInput?: string; } export type TypedIntegrations = { diff --git a/packages/persona-kit/src/emit-schema.test.ts b/packages/persona-kit/src/emit-schema.test.ts index e012a8cb..5081183f 100644 --- a/packages/persona-kit/src/emit-schema.test.ts +++ b/packages/persona-kit/src/emit-schema.test.ts @@ -112,6 +112,20 @@ test('persona schema keeps mount.enabled but drops the moved listener fields', a assert.equal('schedules' in (personaSpec.properties ?? {}), false); // Integration connection config no longer exposes triggers. assert.equal('triggers' in (definitions.PersonaIntegrationConfig.properties ?? {}), false); + assert.equal( + definitions.PersonaIntegrationConfig.properties?.optional && + definitions.PersonaIntegrationConfig.properties.optional !== true + ? definitions.PersonaIntegrationConfig.properties.optional.type + : undefined, + 'boolean' + ); + assert.equal( + definitions.PersonaIntegrationConfig.properties?.enabledByInput && + definitions.PersonaIntegrationConfig.properties.enabledByInput !== true + ? definitions.PersonaIntegrationConfig.properties.enabledByInput.type + : undefined, + 'string' + ); const integrationConfig = definitions.PersonaIntegrationConfig.properties?.config; assert.equal(integrationConfig && integrationConfig !== true ? integrationConfig.type diff --git a/packages/persona-kit/src/parse.test.ts b/packages/persona-kit/src/parse.test.ts index 474f81b7..a3793e95 100644 --- a/packages/persona-kit/src/parse.test.ts +++ b/packages/persona-kit/src/parse.test.ts @@ -726,6 +726,37 @@ test('parseIntegrations preserves scope (connection-only); rejects persona-level ); }); +test('parseIntegrations accepts optional integrations enabled by persona input', () => { + const i = parseIntegrations( + { + slack: { optional: true, enabledByInput: 'SLACK_CHANNEL' } + }, + 'integrations' + ); + + assert.equal(i?.slack.optional, true); + assert.equal(i?.slack.enabledByInput, 'SLACK_CHANNEL'); +}); + +test('parseIntegrations validates optional integration activation shape', () => { + assert.throws( + () => parseIntegrations({ slack: { optional: 'yes' } }, 'integrations'), + /integrations\.slack\.optional must be a boolean/ + ); + assert.throws( + () => parseIntegrations({ slack: { optional: true } }, 'integrations'), + /integrations\.slack\.enabledByInput is required when optional is true/ + ); + assert.throws( + () => parseIntegrations({ slack: { enabledByInput: 'SLACK_CHANNEL' } }, 'integrations'), + /integrations\.slack\.optional must be true when enabledByInput is set/ + ); + assert.throws( + () => parseIntegrations({ slack: { optional: true, enabledByInput: 'slack_channel' } }, 'integrations'), + /integrations\.slack\.enabledByInput must be an env-style name/ + ); +}); + test('parseIntegrations rejects non-plain adapter config values', () => { assert.throws( () => parseIntegrations({ github: { config: null } }, 'integrations'), diff --git a/packages/persona-kit/src/parse.ts b/packages/persona-kit/src/parse.ts index 21b98a49..10a43b1a 100644 --- a/packages/persona-kit/src/parse.ts +++ b/packages/persona-kit/src/parse.ts @@ -646,7 +646,7 @@ export function parseIntegrationConfig( if (!isObject(value)) { throw new Error(`${context} must be an object`); } - const { source, scope, config } = value; + const { source, scope, config, optional, enabledByInput } = value; // Hard cut: triggers moved from the persona to the agent. A persona // integration is connection-config only (source + scope). Fail loudly so @@ -688,6 +688,28 @@ export function parseIntegrationConfig( out.config = config; } + if (optional !== undefined) { + if (typeof optional !== 'boolean') { + throw new Error(`${context}.optional must be a boolean if provided`); + } + out.optional = optional; + } + + if (enabledByInput !== undefined) { + if (typeof enabledByInput !== 'string' || !enabledByInput.trim()) { + throw new Error(`${context}.enabledByInput must be a non-empty string if provided`); + } + assertInputName(enabledByInput, `${context}.enabledByInput`); + out.enabledByInput = enabledByInput; + } + + if (out.optional === true && out.enabledByInput === undefined) { + throw new Error(`${context}.enabledByInput is required when optional is true`); + } + if (out.enabledByInput !== undefined && out.optional !== true) { + throw new Error(`${context}.optional must be true when enabledByInput is set`); + } + return out; } diff --git a/packages/persona-kit/src/types.ts b/packages/persona-kit/src/types.ts index 1497fcfe..01c2c4c1 100644 --- a/packages/persona-kit/src/types.ts +++ b/packages/persona-kit/src/types.ts @@ -246,11 +246,19 @@ export type IntegrationSource = * `config` is a forward-compatible adapter passthrough. Persona-kit validates * only that it is an object; provider adapters own the nested schema * (for example GitHub materialization policy). + * + * Optional integrations are deploy-time choices controlled by persona inputs: + * set `optional: true` and `enabledByInput: "SLACK_CHANNEL"` to include this + * provider only when that input resolves to a non-empty value. Inactive + * optional providers are skipped before deploy connects integrations or + * registers provider triggers. */ export interface PersonaIntegrationConfig { source?: IntegrationSource; scope?: Record; config?: Record; + optional?: boolean; + enabledByInput?: string; } /** From 2833c1aa9acddbbb71233a5b86dd4b00156f86c6 Mon Sep 17 00:00:00 2001 From: Ricky Schema Cascade Date: Wed, 24 Jun 2026 15:25:32 +0200 Subject: [PATCH 2/2] fix optional integration activation inputs --- packages/deploy/src/deploy.test.ts | 260 ++++++++++++++++++++++++++--- packages/deploy/src/deploy.ts | 89 ++++++++-- 2 files changed, 308 insertions(+), 41 deletions(-) diff --git a/packages/deploy/src/deploy.test.ts b/packages/deploy/src/deploy.test.ts index bbd92e6f..bb7188ac 100644 --- a/packages/deploy/src/deploy.test.ts +++ b/packages/deploy/src/deploy.test.ts @@ -157,6 +157,34 @@ async function withWorkspaceEnv( } } +async function withProcessEnv( + env: Record, + fn: () => Promise +): Promise { + const previous = new Map(); + for (const key of Object.keys(env)) { + previous.set(key, process.env[key]); + const next = env[key]; + if (next === undefined) { + delete process.env[key]; + } else { + process.env[key] = next; + } + } + + try { + return await fn(); + } finally { + for (const [key, value] of previous) { + if (value === undefined) { + delete process.env[key]; + } else { + process.env[key] = value; + } + } + } +} + async function withCloudSessionEnv(fn: () => Promise): Promise { const previous = { CLOUD_API_URL: process.env.CLOUD_API_URL, @@ -725,29 +753,213 @@ test('deploy activates optional integrations from supplied persona inputs', asyn }; try { - const result = await deploy( - { - personaPath, - mode: 'dev', - io, - inputs: { SLACK_CHANNEL: 'C1' } + await withProcessEnv({ TELEGRAM_CHAT: undefined }, async () => { + const result = await deploy( + { + personaPath, + mode: 'dev', + io, + inputs: { SLACK_CHANNEL: 'C1' } + }, + { + workspaceAuth, + integrations, + bundle, + modes: { dev: devLauncher } + } + ); + + assert.deepEqual(checked, ['slack']); + assert.deepEqual(connected, ['slack']); + assert.deepEqual(result.connectedIntegrations, ['slack']); + assert.deepEqual(stagedIntegrationKeys, ['slack']); + assert.deepEqual(launchedTriggerKeys, ['slack']); + assert.ok( + io.messages.find((m) => m.message.includes('integrations.telegram: optional; skipped')) + ); + }); + } finally { + await cleanup(); + } +}); + +test('deploy collects picker-backed input before pruning an optional integration', async () => { + const { personaPath, cleanup } = await withTempPersona( + basePersonaJson({ + integrations: { + slack: { optional: true, enabledByInput: 'SLACK_CHANNEL' }, + telegram: { optional: true, enabledByInput: 'TELEGRAM_CHAT' } }, - { - workspaceAuth, - integrations, - bundle, - modes: { dev: devLauncher } + inputs: { + SLACK_CHANNEL: { + description: 'Slack channel', + optional: true, + picker: { provider: 'slack', resource: 'channels' } + }, + TELEGRAM_CHAT: { description: 'Telegram chat', optional: true } } - ); + }), + SLACK_TELEGRAM_AGENT_SRC + ); + const io = createBufferedIO(); + io.scriptAnswers(['1']); + const checked: string[] = []; + const connected: string[] = []; + let stagedIntegrationKeys: string[] = []; + let launchedTriggerKeys: string[] = []; + let launchedInputs: Record | undefined; + let launchedEnv: Record | undefined; + const workspaceAuth: WorkspaceAuth = { + async resolveWorkspace() { + return { workspace: 'ws-test', token: 'tok' }; + } + }; + const integrations: IntegrationConnectResolver = { + async isConnected({ provider }) { + checked.push(provider); + return false; + }, + async connect({ provider }) { + connected.push(provider); + return { connectionId: `conn-${provider}` }; + } + }; + const integrationOptions: IntegrationOptionsResolver = { + async list({ provider, resource }) { + assert.equal(provider, 'slack'); + assert.equal(resource, 'channels'); + return [ + { value: 'C1', label: 'general' }, + { value: 'C2', label: 'deploys' } + ]; + } + }; + const bundle: BundleStager = { + async stage(input) { + stagedIntegrationKeys = Object.keys(input.persona.integrations ?? {}); + return successfulBundleStager().stage(input); + } + }; + const devLauncher: ModeLauncher = { + async launch(input: ModeLaunchInput) { + launchedTriggerKeys = Object.keys(input.agent.triggers ?? {}); + launchedInputs = input.inputs; + launchedEnv = input.env; + return { + id: 'pid-optional-picker', + async stop() { + /* no-op */ + }, + done: Promise.resolve({ code: 0 }) + }; + } + }; - assert.deepEqual(checked, ['slack']); - assert.deepEqual(connected, ['slack']); - assert.deepEqual(result.connectedIntegrations, ['slack']); - assert.deepEqual(stagedIntegrationKeys, ['slack']); - assert.deepEqual(launchedTriggerKeys, ['slack']); - assert.ok( - io.messages.find((m) => m.message.includes('integrations.telegram: optional; skipped')) - ); + try { + await withProcessEnv({ SLACK_CHANNEL: undefined, TELEGRAM_CHAT: undefined }, async () => { + const result = await deploy( + { personaPath, mode: 'dev', io }, + { + workspaceAuth, + integrations, + integrationOptions, + bundle, + modes: { dev: devLauncher } + } + ); + + assert.deepEqual(checked, ['slack']); + assert.deepEqual(connected, ['slack']); + assert.deepEqual(result.connectedIntegrations, ['slack']); + assert.deepEqual(stagedIntegrationKeys, ['slack']); + assert.deepEqual(launchedTriggerKeys, ['slack']); + assert.deepEqual(launchedInputs, { SLACK_CHANNEL: 'C1' }); + assert.equal(launchedEnv?.WORKFORCE_INPUT_SLACK_CHANNEL, 'C1'); + assert.ok( + io.messages.find((m) => m.message.includes('integrations.telegram: optional; skipped')) + ); + }); + } finally { + await cleanup(); + } +}); + +test('deploy forwards env-resolved optional activation input values to launchers', async () => { + const { personaPath, cleanup } = await withTempPersona( + basePersonaJson({ + integrations: { + slack: { optional: true, enabledByInput: 'SLACK_CHANNEL' } + }, + inputs: { + SLACK_CHANNEL: { + description: 'Slack channel', + optional: true, + env: 'AW_SLACK_CHANNEL' + } + } + }), + `import { defineAgent } from '@agentworkforce/runtime'; +export default defineAgent({ + triggers: { slack: [{ on: 'message.created' }] }, + handler: async () => {} +}); +` + ); + const io = createBufferedIO(); + const checked: string[] = []; + let launchedInputs: Record | undefined; + let launchedEnv: Record | undefined; + const workspaceAuth: WorkspaceAuth = { + async resolveWorkspace() { + return { workspace: 'ws-test', token: 'tok' }; + } + }; + const integrations: IntegrationConnectResolver = { + async isConnected({ provider }) { + checked.push(provider); + return true; + }, + async connect() { + throw new Error('should not connect when already connected'); + } + }; + + try { + await withProcessEnv({ AW_SLACK_CHANNEL: 'C-env' }, async () => { + const result = await deploy( + { personaPath, mode: 'cloud', io }, + { + workspaceAuth, + integrations, + providerConfigKeys: { + async resolve() { + return undefined; + } + }, + bundle: successfulBundleStager(), + modes: { + cloud: { + async launch(input: ModeLaunchInput) { + launchedInputs = input.inputs; + launchedEnv = input.env; + return { + id: 'cloud-env-input', + async stop() { + /* no-op */ + }, + done: Promise.resolve({ code: 0 }) + }; + } + } + } + } + ); + + assert.deepEqual(checked, ['slack']); + assert.deepEqual(result.connectedIntegrations, ['slack']); + assert.deepEqual(launchedInputs, { SLACK_CHANNEL: 'C-env' }); + assert.equal(launchedEnv?.WORKFORCE_INPUT_SLACK_CHANNEL, 'C-env'); + }); } finally { await cleanup(); } @@ -768,9 +980,11 @@ test('deploy refuses an agent whose optional integration inputs leave no active SLACK_TELEGRAM_AGENT_SRC ); try { - await assert.rejects( - deploy({ personaPath, mode: 'dev', io: createBufferedIO() }), - /no active listeners after optional integrations were applied/ + await withProcessEnv({ SLACK_CHANNEL: undefined, TELEGRAM_CHAT: undefined }, async () => + assert.rejects( + deploy({ personaPath, mode: 'dev', io: createBufferedIO() }), + /no active listeners after optional integrations were applied/ + ) ); } finally { await cleanup(); diff --git a/packages/deploy/src/deploy.ts b/packages/deploy/src/deploy.ts index d8b78df7..af7273c8 100644 --- a/packages/deploy/src/deploy.ts +++ b/packages/deploy/src/deploy.ts @@ -132,12 +132,14 @@ export async function deploy(opts: DeployOptions, resolvers: DeployResolvers = { const mode: DeployMode = opts.mode ?? pickMode(opts); warnings.push(...preflight.warnings); for (const w of preflight.warnings) io.warn(w); - const activePreflight = selectActiveOptionalIntegrations(preflight, opts.inputs ?? {}); - for (const skipped of activePreflight.skippedOptionalIntegrations) { - io.info( - `integrations.${skipped.provider}: optional; skipped because input ${skipped.enabledByInput} is unset` - ); - } + const canCollectPickerInputs = + opts.dryRun !== true + && !opts.bundleOut + && opts.noPrompt !== true + && (mode === 'cloud' || resolvers.integrationOptions !== undefined); + let activePreflight = selectActiveOptionalIntegrations(preflight, opts.inputs ?? {}, process.env, { + deferPickerBacked: canCollectPickerInputs + }); io.info( `persona ${activePreflight.persona.id}: ${activePreflight.integrations.length} integration(s), ${activePreflight.schedules.length} schedule(s)` @@ -277,7 +279,10 @@ export async function deploy(opts: DeployOptions, resolvers: DeployResolvers = { // into a choose-from-a-list prompt, now that the backing integrations are // connected. Cloud mode gets a cloud-backed resolver by default; callers can // inject their own (or a fake for tests) via `resolvers.integrationOptions`. - let resolvedInputs: Record = { ...(opts.inputs ?? {}) }; + let resolvedInputs: Record = { + ...(opts.inputs ?? {}), + ...activePreflight.resolvedInputValues + }; const optionsResolver = resolvers.integrationOptions ?? (mode === 'cloud' @@ -297,6 +302,20 @@ export async function deploy(opts: DeployOptions, resolvers: DeployResolvers = { ...(opts.noPrompt ? { noPrompt: true } : {}) }); } + activePreflight = selectActiveOptionalIntegrations(preflight, resolvedInputs); + for (const skipped of activePreflight.skippedOptionalIntegrations) { + io.info( + `integrations.${skipped.provider}: optional; skipped because input ${skipped.enabledByInput} is unset` + ); + } + validateActiveAgent(activePreflight); + resolvedInputs = { + ...resolvedInputs, + ...activePreflight.resolvedInputValues + }; + const activeConnectedIntegrations = connectedIntegrations.filter((provider) => + activePreflight.integrations.includes(provider) + ); const bundleDir = path.resolve( path.join(activePreflight.personaDir, '.workforce', 'build', activePreflight.persona.id) @@ -322,6 +341,11 @@ export async function deploy(opts: DeployOptions, resolvers: DeployResolvers = { enabled: resolvers.integrations === undefined, ...(providerConfigKeys ? { providerConfigKeys } : {}) }); + const inputEnv = toInputEnv(resolvedInputs); + const launchEnv = { + ...(runtimeEnv ?? {}), + ...inputEnv + }; io.info(`mode: ${mode}`); const launcher = resolveLauncher(mode, resolvers); @@ -331,7 +355,7 @@ export async function deploy(opts: DeployOptions, resolvers: DeployResolvers = { bundle, workspace, io, - ...(runtimeEnv ? { env: runtimeEnv } : {}), + ...(Object.keys(launchEnv).length > 0 ? { env: launchEnv } : {}), ...(activeToken ? { workspaceToken: activeToken } : {}), ...(opts.detach ? { detach: true } : {}), ...(opts.byoSandbox ? { byoSandbox: true } : {}), @@ -352,7 +376,7 @@ export async function deploy(opts: DeployOptions, resolvers: DeployResolvers = { mode, workspace, bundleDir, - connectedIntegrations, + connectedIntegrations: activeConnectedIntegrations, schedules: activePreflight.schedules, runHandle: handle, warnings @@ -361,22 +385,25 @@ export async function deploy(opts: DeployOptions, resolvers: DeployResolvers = { type ActiveDeployPreflight = DeployPreflight & { skippedOptionalIntegrations: Array<{ provider: string; enabledByInput: string }>; + resolvedInputValues: Record; }; function selectActiveOptionalIntegrations( preflight: DeployPreflight, inputs: Record, - env: NodeJS.ProcessEnv = process.env + env: NodeJS.ProcessEnv = process.env, + options: { deferPickerBacked?: boolean } = {} ): ActiveDeployPreflight { const integrations = preflight.persona.integrations ?? {}; const entries = Object.entries(integrations); if (!entries.some(([, cfg]) => cfg?.optional === true)) { - return { ...preflight, skippedOptionalIntegrations: [] }; + return { ...preflight, skippedOptionalIntegrations: [], resolvedInputValues: {} }; } const activeIntegrations: NonNullable = {}; const inactiveTriggerProviders = new Set(); const skippedOptionalIntegrations: ActiveDeployPreflight['skippedOptionalIntegrations'] = []; + const resolvedInputValues: Record = {}; for (const [provider, cfg] of entries) { if (cfg?.optional !== true) { @@ -385,7 +412,18 @@ function selectActiveOptionalIntegrations( } const enabledByInput = cfg.enabledByInput; - if (enabledByInput && personaInputIsSet(preflight.persona.inputs?.[enabledByInput], enabledByInput, inputs, env)) { + const inputSpec = enabledByInput ? preflight.persona.inputs?.[enabledByInput] : undefined; + const resolved = enabledByInput + ? resolvePersonaInputValue(inputSpec, enabledByInput, inputs, env) + : undefined; + if (enabledByInput && resolved !== undefined) { + activeIntegrations[provider] = cfg; + if (!Object.prototype.hasOwnProperty.call(inputs, enabledByInput)) { + resolvedInputValues[enabledByInput] = resolved; + } + continue; + } + if (options.deferPickerBacked && enabledByInput && isPickerBackedByProvider(inputSpec, provider)) { activeIntegrations[provider] = cfg; continue; } @@ -413,24 +451,39 @@ function selectActiveOptionalIntegrations( persona, agent, integrations: activeIntegrationKeys, - skippedOptionalIntegrations + skippedOptionalIntegrations, + resolvedInputValues }; } -function personaInputIsSet( +function resolvePersonaInputValue( spec: PersonaInputSpec | undefined, inputName: string, inputs: Record, env: NodeJS.ProcessEnv -): boolean { +): string | undefined { const explicit = inputs[inputName]; - if (typeof explicit === 'string') return explicit.trim().length > 0; + if (typeof explicit === 'string') return explicit.trim().length > 0 ? explicit : undefined; const envName = spec?.env ?? inputName; const envValue = env[envName]; - if (typeof envValue === 'string') return envValue.trim().length > 0; + if (typeof envValue === 'string' && envValue.trim().length > 0) return envValue; - return typeof spec?.default === 'string' && spec.default.trim().length > 0; + return typeof spec?.default === 'string' && spec.default.trim().length > 0 + ? spec.default + : undefined; +} + +function isPickerBackedByProvider(spec: PersonaInputSpec | undefined, provider: string): boolean { + return spec?.picker?.provider === provider; +} + +const INPUT_ENV_PREFIX = 'WORKFORCE_INPUT_'; + +function toInputEnv(inputs: Record): Record { + return Object.fromEntries( + Object.entries(inputs).map(([key, value]) => [`${INPUT_ENV_PREFIX}${key}`, value]) + ); } function filterInactiveTriggers(