diff --git a/packages/persona-kit/package.json b/packages/persona-kit/package.json index ca4b781e..1c639b35 100644 --- a/packages/persona-kit/package.json +++ b/packages/persona-kit/package.json @@ -41,7 +41,7 @@ "lint": "tsc -p tsconfig.json --noEmit" }, "dependencies": { - "@relayfile/adapter-core": "^0.3.44", + "@relayfile/adapter-core": "^0.3.50", "@relayfile/local-mount": "^0.7.24" }, "devDependencies": { diff --git a/packages/persona-kit/schemas/persona.schema.json b/packages/persona-kit/schemas/persona.schema.json index 32572532..ce143528 100644 --- a/packages/persona-kit/schemas/persona.schema.json +++ b/packages/persona-kit/schemas/persona.schema.json @@ -425,9 +425,13 @@ "additionalProperties": { "type": "string" } + }, + "config": { + "type": "object", + "additionalProperties": {} } }, - "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." + "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)." }, "IntegrationSource": { "anyOf": [ diff --git a/packages/persona-kit/src/__fixtures__/personas/full.json b/packages/persona-kit/src/__fixtures__/personas/full.json index 57e769f4..1729d644 100644 --- a/packages/persona-kit/src/__fixtures__/personas/full.json +++ b/packages/persona-kit/src/__fixtures__/personas/full.json @@ -29,6 +29,65 @@ "github": { "scope": { "repo": "AgentWorkforce/workforce" + }, + "config": { + "materialization": { + "default": "lazy", + "webhookWritesForLazyRepos": true, + "rules": [ + { + "repos": [ + "AgentWorkforce/workforce" + ], + "resources": [ + "issues", + "pulls" + ], + "issues": { + "mode": "eager", + "filter": { + "state": "open", + "labels": [ + "bug" + ] + } + }, + "pulls": "eager" + } + ] + } + } + }, + "gitlab": { + "scope": { + "projectPath": "AgentWorkforce/workforce" + }, + "config": { + "materialization": { + "default": "lazy", + "webhookWritesForLazyProjects": true, + "rules": [ + { + "projects": [ + "AgentWorkforce/workforce" + ], + "resources": [ + "issues", + "merge_requests" + ], + "issues": { + "mode": "eager", + "filter": { + "state": "opened", + "labels": [ + "bug" + ] + } + }, + "merge_requests": "eager" + } + ] + } } }, "linear": {}, diff --git a/packages/persona-kit/src/define.test.ts b/packages/persona-kit/src/define.test.ts index f883471a..d02b81ad 100644 --- a/packages/persona-kit/src/define.test.ts +++ b/packages/persona-kit/src/define.test.ts @@ -5,6 +5,8 @@ import { KNOWN_SCOPE_KEY_CATALOG, definePersona, parsePersonaSpec, + type GitLabMaterializationPolicy, + type GitHubMaterializationPolicy, type ScopeKeysFor, type TypedScopeMap, type TypedTriggerMap @@ -23,9 +25,42 @@ test('definePersona returns authored specs that parse successfully', () => { } }, // Personas declare integration *connections* only — event triggers moved - // to the agent (defineAgent). Connection config = source + scope. + // to the agent (defineAgent). Connection config = source + scope + adapter config. integrations: { - github: { scope: { repo: 'AgentWorkforce/workforce' } }, + github: { + scope: { repo: 'AgentWorkforce/workforce' }, + config: { + materialization: { + default: 'lazy', + webhookWritesForLazyRepos: true, + rules: [ + { + repos: ['AgentWorkforce/workforce'], + resources: ['issues', 'pulls'], + issues: { mode: 'eager', filter: { state: 'open', labels: ['bug'] } }, + pulls: 'eager' + } + ] + } + } + }, + gitlab: { + scope: { projectPath: 'AgentWorkforce/workforce' }, + config: { + materialization: { + default: 'lazy', + webhookWritesForLazyProjects: true, + rules: [ + { + projects: ['AgentWorkforce/workforce'], + resources: ['issues', 'merge_requests'], + issues: { mode: 'eager', filter: { state: 'opened', labels: ['bug'] } }, + merge_requests: 'eager' + } + ] + } + } + }, linear: {}, slack: {}, confluence: {}, @@ -46,7 +81,31 @@ test('definePersona returns authored specs that parse successfully', () => { assert.equal(parsed.skills.length, 0); assert.equal(parsed.inputs?.TOPIC.default, 'pull requests'); assert.equal(parsed.integrations?.github.scope?.repo, 'AgentWorkforce/workforce'); + assert.deepEqual(parsed.integrations?.github.config?.materialization, { + default: 'lazy', + webhookWritesForLazyRepos: true, + rules: [ + { + repos: ['AgentWorkforce/workforce'], + resources: ['issues', 'pulls'], + issues: { mode: 'eager', filter: { state: 'open', labels: ['bug'] } }, + pulls: 'eager' + } + ] + }); assert.equal(parsed.integrations?.customProvider.source?.kind, 'deployer_user'); + assert.deepEqual(parsed.integrations?.gitlab.config?.materialization, { + default: 'lazy', + webhookWritesForLazyProjects: true, + rules: [ + { + projects: ['AgentWorkforce/workforce'], + resources: ['issues', 'merge_requests'], + issues: { mode: 'eager', filter: { state: 'opened', labels: ['bug'] } }, + merge_requests: 'eager' + } + ] + }); assert.deepEqual(parsed.capabilities, { review: true, conflictAutofix: { enabled: false } @@ -75,6 +134,113 @@ test('TypedTriggerMap gives per-provider event autocomplete; arbitrary providers assert.equal(triggers.customProvider?.[0]?.on, 'custom.event'); }); +test('definePersona types github/gitlab materialization config but keeps unknown provider config generic', () => { + const githubMaterialization: GitHubMaterializationPolicy = { + default: 'lazy', + rules: [ + { + repos: ['AgentWorkforce/workforce'], + eager: true, + issues: { mode: 'eager', since: '2026-01-01T00:00:00.000Z' }, + pulls: { mode: 'lazy', filter: { state: 'all' } } + } + ] + }; + const gitlabMaterialization: GitLabMaterializationPolicy = { + default: 'lazy', + rules: [ + { + projects: ['AgentWorkforce/workforce'], + resources: ['merge_requests', 'issues', 'pipelines', 'commits'], + merge_requests: { mode: 'eager', filter: { state: 'merged' } }, + issues: { mode: 'eager', since: '2026-01-01T00:00:00.000Z' }, + pipelines: 'lazy', + commits: { mode: 'eager', incremental: true } + } + ] + }; + + const persona = definePersona({ + id: 'adapter-config-author', + intent: 'review', + description: 'Adapter config typing fixture.', + integrations: { + github: { + scope: { repo: 'AgentWorkforce/workforce' }, + config: { materialization: githubMaterialization } + }, + gitlab: { + scope: { projectPath: 'AgentWorkforce/workforce' }, + config: { materialization: gitlabMaterialization } + }, + customProvider: { + config: { anyFutureAdapterField: { stays: true } } + } + }, + onEvent: './agent.ts', + harnessSettings: { reasoning: 'low', timeoutSeconds: 60 } + }); + + const issuesPolicy = persona.integrations?.github?.config?.materialization?.rules?.[0]?.issues; + assert.equal( + issuesPolicy && typeof issuesPolicy === 'object' ? issuesPolicy.mode : undefined, + 'eager' + ); + const mergeRequestPolicy = persona.integrations?.gitlab?.config?.materialization?.rules?.[0]?.merge_requests; + assert.equal( + mergeRequestPolicy && typeof mergeRequestPolicy === 'object' + ? mergeRequestPolicy.filter?.state + : undefined, + 'merged' + ); + assert.deepEqual(persona.integrations?.customProvider?.config, { + anyFutureAdapterField: { stays: true } + }); + + definePersona({ + id: 'bad-github-materialization-mode', + intent: 'review', + description: 'GitHub materialization aliases are adapter-runtime inputs, not typed authoring.', + integrations: { + github: { + config: { + materialization: { + // @ts-expect-error persona-kit authoring exposes canonical lazy/eager modes + default: 'all' + } + } + } + }, + onEvent: './agent.ts', + harnessSettings: { reasoning: 'low', timeoutSeconds: 60 } + }); + + definePersona({ + id: 'bad-gitlab-materialization-state', + intent: 'review', + description: 'GitLab materialization states are typed separately from GitHub states.', + integrations: { + gitlab: { + config: { + materialization: { + rules: [ + { + merge_requests: { + mode: 'eager', + // @ts-expect-error GitLab uses opened/closed/locked/merged/all, not GitHub's open + filter: { state: 'open' } + } + } + ] + } + } + } + }, + onEvent: './agent.ts', + harnessSettings: { reasoning: 'low', timeoutSeconds: 60 } + }); +}); + test('TypedScopeMap gives per-provider scope key autocomplete while allowing future keys', () => { const githubScope: TypedScopeMap<'github'> = { owner: 'AgentWorkforce', @@ -84,10 +250,16 @@ test('TypedScopeMap gives per-provider scope key autocomplete while allowing fut const customScope: TypedScopeMap<'customProvider'> = { anyKey: 'any-value' }; + const gitlabScope: TypedScopeMap<'gitlab'> = { + projectPath: 'AgentWorkforce/workforce' + }; const githubKey: ScopeKeysFor<'github'> = 'repo'; + const gitlabKey: ScopeKeysFor<'gitlab'> = 'projectPath'; // @ts-expect-error github has no catalogued "channel" scope key const badGithubKey: ScopeKeysFor<'github'> = 'channel'; + // @ts-expect-error gitlab has no catalogued "repo" scope key + const badGitlabKey: ScopeKeysFor<'gitlab'> = 'repo'; // Providers with no catalogued scope keys (slack today) still accept // arbitrary keys via TypedScopeMap's index signature — no typing regression. @@ -95,9 +267,12 @@ test('TypedScopeMap gives per-provider scope key autocomplete while allowing fut assert.equal(githubScope[githubKey], 'workforce'); assert.deepEqual([...KNOWN_SCOPE_KEY_CATALOG.github], ['owner', 'repo']); + assert.equal(gitlabScope[gitlabKey], 'AgentWorkforce/workforce'); + assert.deepEqual([...KNOWN_SCOPE_KEY_CATALOG.gitlab], ['projectPath']); assert.equal(customScope.anyKey, 'any-value'); assert.equal(slackScope.channel, 'C123'); assert.equal(badGithubKey, 'channel'); + assert.equal(badGitlabKey, 'repo'); }); test('definePersona types tags against the closed PersonaTag vocabulary', () => { diff --git a/packages/persona-kit/src/define.ts b/packages/persona-kit/src/define.ts index 76eb2adb..ca6b9b95 100644 --- a/packages/persona-kit/src/define.ts +++ b/packages/persona-kit/src/define.ts @@ -14,6 +14,7 @@ import type { import type { KnownProviderName, KnownTriggerName } from './triggers.js'; import type { ScopeKey, ScopeKeyProvider } from './scope-keys.js'; import type { KnownPersonaTag } from './constants.js'; +import type { AdapterMaterializationMode } from '@relayfile/adapter-core'; export type TriggerNameFor

= P extends KnownProviderName ? KnownTriggerName

| (string & {}) @@ -62,15 +63,125 @@ export type TypedScopeMap

= { [key: string]: string; }; +export interface TypedAdapterMaterializationFilter { + state?: State; + labels?: readonly string[]; + since?: string; +} + +export type TypedAdapterResourceMaterializationPolicy = + | AdapterMaterializationMode + | { + mode?: AdapterMaterializationMode; + filter?: TypedAdapterMaterializationFilter; + since?: string; + incremental?: boolean; + }; + +export type TypedAdapterMaterializationRule< + Resource extends string, + State extends string = string, + TargetKey extends string = 'targets' +> = { + resources?: readonly Resource[]; + filter?: TypedAdapterMaterializationFilter; + since?: string; + incremental?: boolean; + eager?: boolean; +} & Partial>> + & Partial>; + +export type TypedAdapterMaterializationPolicy< + Resource extends string, + State extends string = string, + TargetKey extends string = 'targets', + WebhookWritesKey extends string = 'webhookWritesForLazyTargets' +> = { + default?: AdapterMaterializationMode; + rules?: readonly TypedAdapterMaterializationRule[]; +} & Partial>; + +export type GitHubMaterializationMode = AdapterMaterializationMode; +export type GitHubMaterializationResource = 'issues' | 'pulls'; +export type GitHubMaterializationState = 'open' | 'closed' | 'all'; +export type GitHubMaterializationFilter = + TypedAdapterMaterializationFilter; +export type GitHubMaterializationResourcePolicy = + TypedAdapterResourceMaterializationPolicy; +export type GitHubMaterializationRule = TypedAdapterMaterializationRule< + GitHubMaterializationResource, + GitHubMaterializationState, + 'repos' +>; +export type GitHubMaterializationPolicy = TypedAdapterMaterializationPolicy< + GitHubMaterializationResource, + GitHubMaterializationState, + 'repos', + 'webhookWritesForLazyRepos' +>; + +export interface GitHubAdapterConfig { + /** + * Relayfile GitHub adapter materialization policy. Persona-kit exposes the + * canonical lazy/eager modes; adapter-side aliases remain an adapter detail. + */ + materialization?: GitHubMaterializationPolicy; + [key: string]: unknown; +} + +export type GitLabMaterializationMode = AdapterMaterializationMode; +export type GitLabMaterializationResource = + | 'merge_requests' + | 'issues' + | 'pipelines' + | 'commits'; +export type GitLabMaterializationState = + | 'opened' + | 'closed' + | 'locked' + | 'merged' + | 'all'; +export type GitLabMaterializationFilter = + TypedAdapterMaterializationFilter; +export type GitLabMaterializationResourcePolicy = + TypedAdapterResourceMaterializationPolicy; +export type GitLabMaterializationRule = TypedAdapterMaterializationRule< + GitLabMaterializationResource, + GitLabMaterializationState, + 'projects' +>; +export type GitLabMaterializationPolicy = TypedAdapterMaterializationPolicy< + GitLabMaterializationResource, + GitLabMaterializationState, + 'projects', + 'webhookWritesForLazyProjects' +>; + +export interface GitLabAdapterConfig { + /** + * Relayfile GitLab adapter materialization policy. Persona-kit exposes the + * shared canonical lazy/eager modes; adapter-side aliases remain an adapter detail. + */ + materialization?: GitLabMaterializationPolicy; + [key: string]: unknown; +} + +export type AdapterConfigFor

= P extends 'github' + ? GitHubAdapterConfig + : P extends 'gitlab' + ? GitLabAdapterConfig + : Record; + /** * Per-provider integration **connection** config in typed persona authoring. * Connection-only (source + scope) — event triggers live on the agent * ({@link TypedTriggerMap}), not here. `scope` keys are typed per provider via - * {@link TypedScopeMap}. + * {@link TypedScopeMap}. `config` is forwarded to the provider adapter. */ export interface TypedIntegrationConfig

{ source?: IntegrationSource; scope?: TypedScopeMap

; + config?: AdapterConfigFor

; } export type TypedIntegrations = { diff --git a/packages/persona-kit/src/emit-schema.test.ts b/packages/persona-kit/src/emit-schema.test.ts index 4c419ef3..e012a8cb 100644 --- a/packages/persona-kit/src/emit-schema.test.ts +++ b/packages/persona-kit/src/emit-schema.test.ts @@ -112,6 +112,13 @@ 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); + const integrationConfig = definitions.PersonaIntegrationConfig.properties?.config; + assert.equal(integrationConfig && integrationConfig !== true + ? integrationConfig.type + : undefined, 'object'); + assert.deepEqual(integrationConfig && integrationConfig !== true + ? integrationConfig.additionalProperties + : undefined, {}); assert.equal(personaMount.properties?.enabled && personaMount.properties.enabled !== true ? personaMount.properties.enabled.type : undefined, 'boolean'); diff --git a/packages/persona-kit/src/index.ts b/packages/persona-kit/src/index.ts index fc7ba80a..19e4d0b5 100644 --- a/packages/persona-kit/src/index.ts +++ b/packages/persona-kit/src/index.ts @@ -59,9 +59,30 @@ export type { // Typed persona authoring export { definePersona, + type AdapterConfigFor, + type GitLabAdapterConfig, + type GitLabMaterializationFilter, + type GitLabMaterializationMode, + type GitLabMaterializationPolicy, + type GitLabMaterializationResource, + type GitLabMaterializationResourcePolicy, + type GitLabMaterializationRule, + type GitLabMaterializationState, + type GitHubAdapterConfig, + type GitHubMaterializationFilter, + type GitHubMaterializationMode, + type GitHubMaterializationPolicy, + type GitHubMaterializationResource, + type GitHubMaterializationResourcePolicy, + type GitHubMaterializationRule, + type GitHubMaterializationState, type PersonaDefinition, type ScopeKeysFor, type TriggerNameFor, + type TypedAdapterMaterializationFilter, + type TypedAdapterMaterializationPolicy, + type TypedAdapterMaterializationRule, + type TypedAdapterResourceMaterializationPolicy, type TypedIntegrationConfig, type TypedIntegrations, type TypedScopeMap, @@ -87,6 +108,7 @@ export { isHarness, isIntent, isObject, + isPlainObject, isSidecarMode, isTag, parseAgentSpec, diff --git a/packages/persona-kit/src/parse.test.ts b/packages/persona-kit/src/parse.test.ts index 2c618bb5..474f81b7 100644 --- a/packages/persona-kit/src/parse.test.ts +++ b/packages/persona-kit/src/parse.test.ts @@ -651,12 +651,69 @@ test('parseSchedules validates cron, requires unique names, preserves tz when se test('parseIntegrations preserves scope (connection-only); rejects persona-level triggers', () => { const i = parseIntegrations( { - github: { scope: { repo: 'org/r' } }, + github: { + scope: { repo: 'org/r' }, + config: { + materialization: { + default: 'lazy', + webhookWritesForLazyRepos: true, + rules: [ + { + repos: ['org/r'], + resources: ['issues', 'pulls'], + issues: { mode: 'eager', filter: { state: 'open', labels: ['p0'] } }, + pulls: 'eager' + } + ] + } + } + }, + gitlab: { + scope: { projectPath: 'org/r' }, + config: { + materialization: { + default: 'lazy', + webhookWritesForLazyProjects: true, + rules: [ + { + projects: ['org/r'], + resources: ['issues', 'merge_requests'], + issues: { mode: 'eager', filter: { state: 'opened', labels: ['p0'] } }, + merge_requests: 'eager' + } + ] + } + } + }, linear: {} // no scope — still a declared connection }, 'integrations' ); assert.equal(i?.github.scope?.repo, 'org/r'); + assert.deepEqual(i?.github.config?.materialization, { + default: 'lazy', + webhookWritesForLazyRepos: true, + rules: [ + { + repos: ['org/r'], + resources: ['issues', 'pulls'], + issues: { mode: 'eager', filter: { state: 'open', labels: ['p0'] } }, + pulls: 'eager' + } + ] + }); + assert.deepEqual(i?.gitlab.config?.materialization, { + default: 'lazy', + webhookWritesForLazyProjects: true, + rules: [ + { + projects: ['org/r'], + resources: ['issues', 'merge_requests'], + issues: { mode: 'eager', filter: { state: 'opened', labels: ['p0'] } }, + merge_requests: 'eager' + } + ] + }); // Default-injected source keeps existing personas resolving against // the deploying user's `user_integrations` row. assert.deepEqual(i?.github.source, { kind: 'deployer_user' }); @@ -669,6 +726,17 @@ test('parseIntegrations preserves scope (connection-only); rejects persona-level ); }); +test('parseIntegrations rejects non-plain adapter config values', () => { + assert.throws( + () => parseIntegrations({ github: { config: null } }, 'integrations'), + /integrations\.github\.config must be a plain object/ + ); + assert.throws( + () => parseIntegrations({ github: { config: ['materialization'] } }, 'integrations'), + /integrations\.github\.config must be a plain object/ + ); +}); + test('parseAgentSpec validates launchedBy plus provider-keyed triggers, schedules, and watch', () => { const agent = parseAgentSpec({ launchedBy: 'team-dispatcher', diff --git a/packages/persona-kit/src/parse.ts b/packages/persona-kit/src/parse.ts index 37b08183..21b98a49 100644 --- a/packages/persona-kit/src/parse.ts +++ b/packages/persona-kit/src/parse.ts @@ -52,6 +52,12 @@ export function isObject(value: unknown): value is Record { return typeof value === 'object' && value !== null; } +export function isPlainObject(value: unknown): value is Record { + if (!isObject(value) || Array.isArray(value)) return false; + const proto = Object.getPrototypeOf(value); + return proto === Object.prototype || proto === null; +} + export function isHarness(value: unknown): value is Harness { return typeof value === 'string' && HARNESS_VALUES.includes(value as Harness); } @@ -640,7 +646,7 @@ export function parseIntegrationConfig( if (!isObject(value)) { throw new Error(`${context} must be an object`); } - const { source, scope } = value; + const { source, scope, config } = value; // Hard cut: triggers moved from the persona to the agent. A persona // integration is connection-config only (source + scope). Fail loudly so @@ -675,6 +681,13 @@ export function parseIntegrationConfig( } } + if (config !== undefined) { + if (!isPlainObject(config)) { + throw new Error(`${context}.config must be a plain object if provided`); + } + out.config = config; + } + return out; } diff --git a/packages/persona-kit/src/scope-keys.test.ts b/packages/persona-kit/src/scope-keys.test.ts index e108808e..f396ecbc 100644 --- a/packages/persona-kit/src/scope-keys.test.ts +++ b/packages/persona-kit/src/scope-keys.test.ts @@ -9,24 +9,30 @@ import { // Hard guarantee on the cross-repo seam: persona-kit's typed `scope` keys are // only as good as the catalog it imports from @relayfile/adapter-core/scope-keys. -// If a future adapter-core release drops the export, regresses the github keys, +// If a future adapter-core release drops the export, regresses the github/gitlab keys, // or ships an empty/garbled catalog, these assertions fail persona-kit's CI — // so a broken upstream can't silently degrade scope typing to "any string". -test('consumed scope-key catalog exposes github owner/repo', () => { +test('consumed scope-key catalog exposes github and gitlab keys', () => { assert.ok(KNOWN_SCOPE_KEY_CATALOG, 'KNOWN_SCOPE_KEY_CATALOG must be importable from adapter-core/scope-keys'); assert.deepEqual([...(KNOWN_SCOPE_KEY_CATALOG.github ?? [])], ['owner', 'repo']); + assert.deepEqual([...(KNOWN_SCOPE_KEY_CATALOG.gitlab ?? [])], ['projectPath']); }); test('ScopeKeysFor narrows to the provider scope keys; typed authoring accepts them', () => { // Type-level guard: github resolves to its declared keys. const owner: ScopeKeysFor<'github'> = 'owner'; const repo: ScopeKeysFor<'github'> = 'repo'; + const projectPath: ScopeKeysFor<'gitlab'> = 'projectPath'; // @ts-expect-error 'nope' is not a github scope key const bad: ScopeKeysFor<'github'> = 'nope'; + // @ts-expect-error 'repo' is not a gitlab scope key + const badGitlab: ScopeKeysFor<'gitlab'> = 'repo'; void owner; void repo; + void projectPath; void bad; + void badGitlab; const persona = definePersona({ id: 'scope-typed', @@ -35,10 +41,12 @@ test('ScopeKeysFor narrows to the provider scope keys; typed authoring accepts t integrations: { // Known keys autocomplete + are typed; arbitrary keys stay allowed. github: { scope: { owner: 'acme', repo: 'web' } }, + gitlab: { scope: { projectPath: 'acme/web' } }, linear: { scope: { team: 'ENG' } } }, onEvent: './agent.ts', harnessSettings: { reasoning: 'low', timeoutSeconds: 60 } }); assert.equal(persona.integrations?.github?.scope?.owner, 'acme'); + assert.equal(persona.integrations?.gitlab?.scope?.projectPath, 'acme/web'); }); diff --git a/packages/persona-kit/src/types.ts b/packages/persona-kit/src/types.ts index 0c002daa..1497fcfe 100644 --- a/packages/persona-kit/src/types.ts +++ b/packages/persona-kit/src/types.ts @@ -242,10 +242,15 @@ export type IntegrationSource = * `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. + * + * `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). */ export interface PersonaIntegrationConfig { source?: IntegrationSource; scope?: Record; + config?: Record; } /** diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index bb4e2cc1..9b3df36f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -109,8 +109,8 @@ importers: packages/persona-kit: dependencies: '@relayfile/adapter-core': - specifier: ^0.3.44 - version: 0.3.44(@relayfile/sdk@0.7.40) + specifier: ^0.3.50 + version: 0.3.50(@relayfile/sdk@0.7.40) '@relayfile/local-mount': specifier: ^0.7.24 version: 0.7.24 @@ -960,6 +960,13 @@ packages: peerDependencies: '@relayfile/sdk': '>=0.6.0 <1' + '@relayfile/adapter-core@0.3.50': + resolution: {integrity: sha512-f1ksE475M4D5yQvvW9MCuCM+wCZqI+ynUaTQdvctaw16vgHenxq89mm+p4ZLauhbr/J4JmNvjz3RmK2n031T4Q==} + engines: {node: '>=18'} + hasBin: true + peerDependencies: + '@relayfile/sdk': '>=0.6.0 <1' + '@relayfile/core@0.7.40': resolution: {integrity: sha512-vY48SxZgahnvE0CHDyy/iny17ypnfbX5myVPtocZVNpMiz4dS1iabO70WR+uYVgSiIZR7MpKTPFSs3SxEjQWag==} engines: {node: '>=18'} @@ -3282,6 +3289,14 @@ snapshots: minimatch: 10.2.5 yaml: 2.9.0 + '@relayfile/adapter-core@0.3.50(@relayfile/sdk@0.7.40)': + dependencies: + '@relayfile/sdk': 0.7.40 + '@scalar/postman-to-openapi': 0.6.3 + cheerio: 1.2.0 + minimatch: 10.2.5 + yaml: 2.9.0 + '@relayfile/core@0.7.40': {} '@relayfile/local-mount@0.7.24':