From 2be5f771d6162b6c7d41a42780af1311736fe45e Mon Sep 17 00:00:00 2001 From: Khaliq Date: Tue, 12 May 2026 21:35:06 +0200 Subject: [PATCH] feat(persona-kit): add IntegrationConfig.source discriminator MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduce an `IntegrationSource` discriminated union (`deployer_user` | `workspace` | `workspace_service_account`) and add an optional `source` field on `PersonaIntegrationConfig`. The parser default-injects `{ kind: 'deployer_user' }` when `source` is omitted, so existing personas keep their pre-discriminator behavior. This unblocks the cloud-side two-table integration resolver (cloud#553): without `source`, the resolver cannot know whether a persona's declared integration should look up `user_integrations` or `workspace_integrations`. Surface stays types + parse + fixtures only — runtime resolution wiring waits for workforce#92 to land. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../personas/integration-source-deployer.json | 17 +++ .../integration-source-service-account.json | 18 +++ .../integration-source-workspace.json | 17 +++ packages/persona-kit/src/index.ts | 3 + packages/persona-kit/src/parse.test.ts | 118 +++++++++++++++++- packages/persona-kit/src/parse.ts | 73 ++++++++++- packages/persona-kit/src/types.ts | 25 ++++ 7 files changed, 269 insertions(+), 2 deletions(-) create mode 100644 packages/persona-kit/src/__fixtures__/personas/integration-source-deployer.json create mode 100644 packages/persona-kit/src/__fixtures__/personas/integration-source-service-account.json create mode 100644 packages/persona-kit/src/__fixtures__/personas/integration-source-workspace.json diff --git a/packages/persona-kit/src/__fixtures__/personas/integration-source-deployer.json b/packages/persona-kit/src/__fixtures__/personas/integration-source-deployer.json new file mode 100644 index 0000000..7e515a7 --- /dev/null +++ b/packages/persona-kit/src/__fixtures__/personas/integration-source-deployer.json @@ -0,0 +1,17 @@ +{ + "id": "integration-source-deployer", + "intent": "documentation", + "tags": ["documentation"], + "description": "Fixture: persona declaring a GitHub integration with no explicit source. Exercises the default-injection path that fills in { kind: 'deployer_user' } during parse.", + "harness": "claude", + "model": "anthropic/claude-3-5-sonnet", + "systemPrompt": "Fixture persona for IntegrationSource default-injection. Not used at runtime.", + "harnessSettings": { "reasoning": "medium", "timeoutSeconds": 300 }, + "cloud": true, + "integrations": { + "github": { + "scope": { "repo": "AgentWorkforce/workforce" }, + "triggers": [{ "on": "pull_request.opened" }] + } + } +} diff --git a/packages/persona-kit/src/__fixtures__/personas/integration-source-service-account.json b/packages/persona-kit/src/__fixtures__/personas/integration-source-service-account.json new file mode 100644 index 0000000..2bb07df --- /dev/null +++ b/packages/persona-kit/src/__fixtures__/personas/integration-source-service-account.json @@ -0,0 +1,18 @@ +{ + "id": "integration-source-service-account", + "intent": "documentation", + "tags": ["documentation"], + "description": "Fixture: persona declaring a GitHub integration resolved via a named workspace service account ('release-bot').", + "harness": "claude", + "model": "anthropic/claude-3-5-sonnet", + "systemPrompt": "Fixture persona for IntegrationSource={ kind: 'workspace_service_account', name: 'release-bot' }. Not used at runtime.", + "harnessSettings": { "reasoning": "medium", "timeoutSeconds": 300 }, + "cloud": true, + "integrations": { + "github": { + "source": { "kind": "workspace_service_account", "name": "release-bot" }, + "scope": { "repo": "AgentWorkforce/workforce" }, + "triggers": [{ "on": "pull_request.opened" }] + } + } +} diff --git a/packages/persona-kit/src/__fixtures__/personas/integration-source-workspace.json b/packages/persona-kit/src/__fixtures__/personas/integration-source-workspace.json new file mode 100644 index 0000000..11b7839 --- /dev/null +++ b/packages/persona-kit/src/__fixtures__/personas/integration-source-workspace.json @@ -0,0 +1,17 @@ +{ + "id": "integration-source-workspace", + "intent": "documentation", + "tags": ["documentation"], + "description": "Fixture: persona declaring a Slack integration resolved from the workspace's default workspace_integrations row.", + "harness": "claude", + "model": "anthropic/claude-3-5-sonnet", + "systemPrompt": "Fixture persona for IntegrationSource={ kind: 'workspace' }. Not used at runtime.", + "harnessSettings": { "reasoning": "medium", "timeoutSeconds": 300 }, + "cloud": true, + "integrations": { + "slack": { + "source": { "kind": "workspace" }, + "triggers": [{ "on": "app_mention" }] + } + } +} diff --git a/packages/persona-kit/src/index.ts b/packages/persona-kit/src/index.ts index 9730154..45196bb 100644 --- a/packages/persona-kit/src/index.ts +++ b/packages/persona-kit/src/index.ts @@ -19,6 +19,7 @@ export type { Harness, HarnessSettings, HarnessSkillTarget, + IntegrationSource, McpServerSpec, PermissionMode, PersonaContext, @@ -53,6 +54,7 @@ export { assertSidecarPath, deepFreeze, INPUT_NAME_RE, + INTEGRATION_SOURCE_NAME_RE, isHarness, isIntent, isObject, @@ -61,6 +63,7 @@ export { parseHarnessSettings, parseInputs, parseIntegrationConfig, + parseIntegrationSource, parseIntegrationTrigger, parseIntegrations, parseMcpServers, diff --git a/packages/persona-kit/src/parse.test.ts b/packages/persona-kit/src/parse.test.ts index e201c44..344788b 100644 --- a/packages/persona-kit/src/parse.test.ts +++ b/packages/persona-kit/src/parse.test.ts @@ -1,5 +1,8 @@ import test from 'node:test'; import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import { dirname, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; import { assertInputName, assertSidecarPath, @@ -489,7 +492,10 @@ test('parseIntegrations preserves scope + triggers; rejects empty trigger arrays assert.equal(i?.github.scope?.repo, 'org/r'); assert.equal(i?.github.triggers?.length, 2); assert.equal(i?.github.triggers?.[1].match, '@mention'); - assert.deepEqual(i?.linear, {}); + // Default-injected source keeps existing personas resolving against + // the deploying user's `user_integrations` row. + assert.deepEqual(i?.github.source, { kind: 'deployer_user' }); + assert.deepEqual(i?.linear, { source: { kind: 'deployer_user' } }); assert.throws( () => @@ -509,6 +515,116 @@ test('parseIntegrations preserves scope + triggers; rejects empty trigger arrays ); }); +test('parseIntegrations default-injects source=deployer_user when the persona omits it', () => { + const i = parseIntegrations({ github: {} }, 'integrations'); + assert.deepEqual(i?.github.source, { kind: 'deployer_user' }); +}); + +test('parseIntegrations round-trips all three valid IntegrationSource kinds', () => { + const i = parseIntegrations( + { + github: { source: { kind: 'deployer_user' } }, + slack: { source: { kind: 'workspace' } }, + linear: { + source: { kind: 'workspace_service_account', name: 'release-bot' } + } + }, + 'integrations' + ); + assert.deepEqual(i?.github.source, { kind: 'deployer_user' }); + assert.deepEqual(i?.slack.source, { kind: 'workspace' }); + assert.deepEqual(i?.linear.source, { + kind: 'workspace_service_account', + name: 'release-bot' + }); +}); + +test('parseIntegrations rejects an unknown source.kind with a precise field path', () => { + assert.throws( + () => + parseIntegrations( + { github: { source: { kind: 'org' } } }, + 'integrations' + ), + /integrations\.github\.source\.kind must be one of: deployer_user, workspace, workspace_service_account/ + ); +}); + +test('parseIntegrations rejects workspace_service_account missing name', () => { + assert.throws( + () => + parseIntegrations( + { github: { source: { kind: 'workspace_service_account' } } }, + 'integrations' + ), + /integrations\.github\.source\.name must be a non-empty string when kind="workspace_service_account"/ + ); +}); + +test('parseIntegrations rejects workspace_service_account with non-kebab-case name', () => { + assert.throws( + () => + parseIntegrations( + { + github: { + source: { kind: 'workspace_service_account', name: 'Release_Bot' } + } + }, + 'integrations' + ), + /integrations\.github\.source\.name must be kebab-case/ + ); +}); + +test('IntegrationSource fixtures round-trip through parsePersonaSpec', () => { + // Fixtures live under src/__fixtures__/personas/. The compiled test + // sits at dist/parse.test.js, so resolve back through the package root. + const here = dirname(fileURLToPath(import.meta.url)); + const fixturesRoot = resolve(here, '..', 'src', '__fixtures__', 'personas'); + const load = (name: string) => + JSON.parse(readFileSync(resolve(fixturesRoot, name), 'utf8')); + + const deployer = parsePersonaSpec( + load('integration-source-deployer.json'), + 'documentation' + ); + assert.deepEqual(deployer.integrations?.github.source, { kind: 'deployer_user' }); + + const workspace = parsePersonaSpec( + load('integration-source-workspace.json'), + 'documentation' + ); + assert.deepEqual(workspace.integrations?.slack.source, { kind: 'workspace' }); + + const sa = parsePersonaSpec( + load('integration-source-service-account.json'), + 'documentation' + ); + assert.deepEqual(sa.integrations?.github.source, { + kind: 'workspace_service_account', + name: 'release-bot' + }); +}); + +test('parseIntegrations rejects extra name on deployer_user / workspace kinds', () => { + assert.throws( + () => + parseIntegrations( + { github: { source: { kind: 'deployer_user', name: 'release-bot' } } }, + 'integrations' + ), + /integrations\.github\.source\.name is only allowed when kind="workspace_service_account"/ + ); + assert.throws( + () => + parseIntegrations( + { slack: { source: { kind: 'workspace', name: 'release-bot' } } }, + 'integrations' + ), + /integrations\.slack\.source\.name is only allowed when kind="workspace_service_account"/ + ); +}); + test('parseOnEvent enforces relative path with a supported extension', () => { assert.equal(parseOnEvent('./agent.ts', 'onEvent'), './agent.ts'); assert.equal(parseOnEvent('handlers/main.mjs', 'onEvent'), 'handlers/main.mjs'); diff --git a/packages/persona-kit/src/parse.ts b/packages/persona-kit/src/parse.ts index d5c1209..751761b 100644 --- a/packages/persona-kit/src/parse.ts +++ b/packages/persona-kit/src/parse.ts @@ -12,6 +12,7 @@ import type { CodexSandboxMode, Harness, HarnessSettings, + IntegrationSource, McpServerSpec, PermissionMode, PersonaInputSpec, @@ -471,6 +472,68 @@ export function parseIntegrationTrigger( }; } +/** + * Slug rules for `workspace_service_account.name`: kebab-case, ≤64 chars, + * lowercase ASCII letters/digits/hyphens, no leading/trailing/consecutive + * hyphens. Mirrors the convention used by other persona-kit identifiers. + */ +export const INTEGRATION_SOURCE_NAME_RE = /^[a-z0-9]+(?:-[a-z0-9]+)*$/; +const INTEGRATION_SOURCE_NAME_MAX = 64; + +const INTEGRATION_SOURCE_KINDS = [ + 'deployer_user', + 'workspace', + 'workspace_service_account' +] as const; + +type IntegrationSourceKind = (typeof INTEGRATION_SOURCE_KINDS)[number]; + +function isIntegrationSourceKind(value: unknown): value is IntegrationSourceKind { + return ( + typeof value === 'string' && + INTEGRATION_SOURCE_KINDS.includes(value as IntegrationSourceKind) + ); +} + +export function parseIntegrationSource( + value: unknown, + context: string +): IntegrationSource { + if (!isObject(value)) { + throw new Error(`${context} must be an object`); + } + const { kind, name } = value; + if (!isIntegrationSourceKind(kind)) { + throw new Error( + `${context}.kind must be one of: ${INTEGRATION_SOURCE_KINDS.join(', ')}` + ); + } + if (kind === 'workspace_service_account') { + if (typeof name !== 'string' || !name) { + throw new Error( + `${context}.name must be a non-empty string when kind="workspace_service_account"` + ); + } + if (name.length > INTEGRATION_SOURCE_NAME_MAX) { + throw new Error( + `${context}.name must be ≤${INTEGRATION_SOURCE_NAME_MAX} characters` + ); + } + if (!INTEGRATION_SOURCE_NAME_RE.test(name)) { + throw new Error( + `${context}.name must be kebab-case matching ${INTEGRATION_SOURCE_NAME_RE.source}` + ); + } + return { kind, name }; + } + if (name !== undefined) { + throw new Error( + `${context}.name is only allowed when kind="workspace_service_account"` + ); + } + return { kind }; +} + export function parseIntegrationConfig( value: unknown, context: string @@ -478,10 +541,18 @@ export function parseIntegrationConfig( if (!isObject(value)) { throw new Error(`${context} must be an object`); } - const { scope, triggers } = value; + const { source, scope, triggers } = value; const out: PersonaIntegrationConfig = {}; + // Default-inject `deployer_user` when the persona omits `source` so + // pre-discriminator personas keep parsing unchanged. The cloud-side + // resolver can then trust `source` is always present on parsed specs. + out.source = + source === undefined + ? { kind: 'deployer_user' } + : parseIntegrationSource(source, `${context}.source`); + if (scope !== undefined) { const parsedScope = parseStringMap(scope, `${context}.scope`); if (parsedScope && Object.keys(parsedScope).length > 0) { diff --git a/packages/persona-kit/src/types.ts b/packages/persona-kit/src/types.ts index fff25a5..8f4f165 100644 --- a/packages/persona-kit/src/types.ts +++ b/packages/persona-kit/src/types.ts @@ -152,6 +152,26 @@ export interface PersonaIntegrationTrigger { where?: string; } +/** + * Discriminator on how the cloud-side integration resolver should look up + * the connection backing a persona's declared integration: + * + * - `deployer_user` — resolve via `user_integrations` keyed by the deploying + * user (the default; matches today's behavior). + * - `workspace` — resolve via the workspace's default `workspace_integrations` + * row for this provider. + * - `workspace_service_account` — resolve via a named workspace service + * account (e.g. `release-bot`), letting one workspace expose multiple + * provider identities. + * + * The persona-kit only validates the shape; the cloud resolver enforces + * which sources are actually permitted at deploy time. + */ +export type IntegrationSource = + | { kind: 'deployer_user' } + | { kind: 'workspace' } + | { kind: 'workspace_service_account'; name: string }; + /** * Per-provider integration configuration. The map key is the Relayfile * provider slug (`github`, `linear`, `slack`, `notion`, `jira`). `scope` @@ -159,8 +179,13 @@ export interface PersonaIntegrationTrigger { * github, `{ database: "" }` for notion). `triggers` are flat — all * trigger events for this provider fan into the same `onEvent` handler, * which discriminates on `event.source` + `event.type`. + * + * `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. */ export interface PersonaIntegrationConfig { + source?: IntegrationSource; scope?: Record; triggers?: PersonaIntegrationTrigger[]; }