From fcdc34247c84eba65de1c94c5e54c5578286d642 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 30 Jul 2026 12:05:14 +0000 Subject: [PATCH 1/2] fix(spec,runtime,service-automation): IAutomationService declares the connector registry it already serves (#4127) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The fourth and last dispatcher call site from #4127 calling a method its contract never declared. The first three shipped in #4143; this one was held back because the fix is a type MOVE, not a type addition — `ConnectorDescriptor` was declared in service-automation's engine, one IMPLEMENTATION of `IAutomationService`. A contract cannot name a type living inside its own implementation, so `getConnectorDescriptors` could not be declared until the type had a home in the spec. `IAutomationService` += `getConnectorDescriptors?()`. It is the sibling of `getActionDescriptors`, declared since ADR-0018: the two fill the flow designer's `connector_action` node together — node vocabulary from one, the connector / action / input pickers from the other. Only one was written down. `GET /automation/connectors` has served the other since ADR-0022 by probing for the method and re-typing its own result as `any` to filter on `?type=` — a filter on a field the type system did not know existed, one typo from silently matching nothing and answering an empty registry, which is also what the route legitimately returns when the method is absent, so the failure had no distinguishable symptom. Optional for the same reason `getActionDescriptors` is: a connector registry is a capability of the flow-engine implementation, not a property of every automation slot. A script-runner filling the slot has no connectors to describe and answers an empty registry rather than 404 — `handlerReady` does not apply, since the slot is serveable and only this capability is absent. `ConnectorDescriptor` / `ConnectorActionDescriptor` / `ConnectorOrigin` / `ConnectorState` move to `@objectstack/spec/integration`, beside the ADR-0097 provider contract, for the reason that file already states about itself: they are pure types, so a connector plugin — or a designer client, or the dispatcher — speaks about registered connectors depending only on the spec, with no runtime coupling to the engine. `ConnectorOrigin` is ADR-0097 §4 vocabulary and `ConnectorState` is #3017 vocabulary; neither was ever engine-private in meaning, only in location. Nothing is renamed and no shape changes. service-automation imports the four back and re-exports them from its index — same names, same entry point — so every existing importer compiles unchanged. `ConnectorState` joins that re-export, which it should have been in all along: it is a required field of the descriptor the index has always exported. The dispatcher's connector fixture had already drifted, which is the concrete cost: it declared `{ name, label, type, actions }` and omitted `origin` and `state` — both REQUIRED, and both the fields a designer reads to tell a live declarative instance from a plugin one (ADR-0097 §4), or a dispatchable connector from a degraded one listed honestly rather than hidden (#3017). Nothing caught it, because an undeclared return type cannot be checked against. The fixture is typed now, and a new test pins that origin / state / degradedReason survive the route rather than only name and type. Refs #4127 Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_015T5CeitwV1jXLvsL1A84tw --- ...nnector-descriptors-meet-their-contract.md | 62 +++++++++++++ packages/runtime/src/domains/automation.ts | 11 ++- packages/runtime/src/http-dispatcher.test.ts | 35 +++++++- .../services/service-automation/src/engine.ts | 81 ++++------------- .../services/service-automation/src/index.ts | 20 +++-- .../src/contracts/automation-service.test.ts | 54 +++++++++++ .../spec/src/contracts/automation-service.ts | 20 +++++ .../src/integration/connector-descriptor.ts | 90 +++++++++++++++++++ packages/spec/src/integration/index.ts | 4 + 9 files changed, 299 insertions(+), 78 deletions(-) create mode 100644 .changeset/connector-descriptors-meet-their-contract.md create mode 100644 packages/spec/src/integration/connector-descriptor.ts diff --git a/.changeset/connector-descriptors-meet-their-contract.md b/.changeset/connector-descriptors-meet-their-contract.md new file mode 100644 index 0000000000..9832597c54 --- /dev/null +++ b/.changeset/connector-descriptors-meet-their-contract.md @@ -0,0 +1,62 @@ +--- +"@objectstack/spec": minor +"@objectstack/service-automation": patch +"@objectstack/runtime": patch +--- + +fix(spec,runtime,service-automation): `IAutomationService` declares the connector registry it already serves (#4127) + +The fourth and last of the dispatcher call sites #4127 found calling a method its +contract never declared. The first three shipped in #4143; this one was held back +because the fix is a **type move**, not a type addition — `ConnectorDescriptor` +was declared in `@objectstack/service-automation`'s engine, which is one +*implementation* of `IAutomationService`. A contract cannot name a type that +lives inside its own implementation, so `getConnectorDescriptors` could not be +declared at all until the type had a home in the spec. + +**`IAutomationService` += `getConnectorDescriptors?()`.** It is the sibling of +`getActionDescriptors`, which the contract has declared since ADR-0018: the two +fill the flow designer's `connector_action` node together — node vocabulary from +one, the connector → action → input pickers from the other. Only one of them was +written down. `GET /api/v1/automation/connectors` has served the other since +ADR-0022 by probing for the method and then re-typing its own result as `any` to +filter on `?type=`, which is a filter on a field the type system did not know +existed — one typo from silently matching nothing and answering an empty +registry, which is also what this route legitimately returns when the method is +absent, so the failure had no distinguishable symptom. + +Optional for the same reason `getActionDescriptors` is: a connector registry is a +capability of the flow-engine implementation, not a property of every automation +slot. A script-runner filling the slot has no connectors to describe, and the +route answers an empty registry rather than a 404 — the `handlerReady` posture +does not apply, since the slot is serveable and only this capability is absent. + +**`ConnectorDescriptor` / `ConnectorActionDescriptor` / `ConnectorOrigin` / +`ConnectorState` move to `@objectstack/spec/integration`**, beside the ADR-0097 +provider contract, for the reason that file already states about itself: they are +pure types, so a connector plugin — or a designer client, or the dispatcher — +speaks about registered connectors depending only on the spec, with no runtime +coupling to the engine. `ConnectorOrigin` is ADR-0097 §4 vocabulary and +`ConnectorState` is #3017 vocabulary; neither was ever engine-private in meaning, +only in location. + +Nothing is renamed and no shape changes. `@objectstack/service-automation` +imports the four back and re-exports them from its index — the same names, from +the same entry point — so every existing importer compiles unchanged. +`ConnectorState` joins that re-export, which it should have been in all along: it +is a required field of the descriptor the index has always exported. + +**The test fixture had already drifted, which is the concrete cost.** The +dispatcher's connector mock declared `{ name, label, type, actions }` and omitted +`origin` and `state` — both **required** on `ConnectorDescriptor`, and both the +fields a designer reads to tell a live declarative instance from a plugin one +(ADR-0097 §4), or a dispatchable connector from a degraded one that is listed +honestly rather than hidden (#3017). Nothing caught it, because an undeclared +return type cannot be checked against. The fixture is typed now, so it cannot +drift again, and a new test pins that `origin` / `state` / `degradedReason` +survive the hop through the route rather than only `name` and `type`. + +Verified: `@objectstack/spec` **7089 tests / 272 files** (2 new contract tests), +`@objectstack/service-automation` **457 / 41**, `@objectstack/runtime` +**218 http-dispatcher tests** (1 new), `tsc --noEmit`, `pnpm lint`, the liveness +and empty-state gates, and the three generated-artifact gates — all clean. diff --git a/packages/runtime/src/domains/automation.ts b/packages/runtime/src/domains/automation.ts index 0cebecb5a6..3406d46dc2 100644 --- a/packages/runtime/src/domains/automation.ts +++ b/packages/runtime/src/domains/automation.ts @@ -192,11 +192,16 @@ export async function handleAutomationRequest(deps: DomainHandlerDeps, path: str // empty in baseline and populated by connector plugins (e.g. // @objectstack/connector-rest, @objectstack/connector-slack). if (parts[0] === 'connectors' && parts.length === 1 && m === 'GET') { - if (typeof automationService.getConnectorDescriptors === 'function') { - let connectors = automationService.getConnectorDescriptors() ?? []; + // [#4127] The method is declared on IAutomationService now, so the + // `?type=` filter reads `ConnectorDescriptor['type']` instead of + // re-typing each element as `any` — a filter on a field the contract + // did not know existed was a typo away from silently matching nothing. + const svc = automationService as Pick; + if (typeof svc.getConnectorDescriptors === 'function') { + let connectors = svc.getConnectorDescriptors() ?? []; // Optional filter mirrors the descriptor's connector type. if (query?.type) { - connectors = connectors.filter((c: any) => c?.type === query.type); + connectors = connectors.filter((c) => c?.type === query.type); } return { handled: true, response: deps.success({ connectors, total: connectors.length }) }; } diff --git a/packages/runtime/src/http-dispatcher.test.ts b/packages/runtime/src/http-dispatcher.test.ts index 0ccfae8c18..c46e00d211 100644 --- a/packages/runtime/src/http-dispatcher.test.ts +++ b/packages/runtime/src/http-dispatcher.test.ts @@ -3,6 +3,7 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; import { HttpDispatcher } from './http-dispatcher.js'; import { ObjectKernel } from '@objectstack/core'; import { ApiErrorSchema } from '@objectstack/spec/api'; +import type { ConnectorDescriptor } from '@objectstack/spec/integration'; describe('HttpDispatcher', () => { let kernel: ObjectKernel; @@ -179,11 +180,18 @@ describe('HttpDispatcher', () => { { type: 'http_request', name: 'HTTP Request', category: 'io', paradigms: ['flow', 'approval'], source: 'builtin' }, { type: 'send_sms', name: 'Send SMS', category: 'io', paradigms: ['flow'], source: 'plugin' }, ]), + // [#4127] Typed as `ConnectorDescriptor[]` now that the contract + // declares `getConnectorDescriptors`, so this fixture cannot + // drift from the shape the route serves. The previous untyped + // literal was already missing `origin` and `state` — both + // REQUIRED, and both the fields a designer reads to tell a + // declarative instance from a plugin one, or a degraded + // connector from a live one (#3017). getConnectorDescriptors: vi.fn().mockReturnValue([ - { name: 'rest', label: 'REST', type: 'api', actions: [{ key: 'request', label: 'Request' }] }, - { name: 'slack', label: 'Slack', type: 'api', actions: [{ key: 'chat.postMessage', label: 'Post Message' }] }, - { name: 'pg', label: 'Postgres', type: 'database', actions: [] }, - ]), + { name: 'rest', label: 'REST', type: 'api', origin: 'plugin', state: 'ready', actions: [{ key: 'request', label: 'Request' }] }, + { name: 'slack', label: 'Slack', type: 'api', origin: 'plugin', state: 'ready', actions: [{ key: 'chat.postMessage', label: 'Post Message' }] }, + { name: 'pg', label: 'Postgres', type: 'database', origin: 'declarative', state: 'degraded', degradedReason: 'upstream unreachable', actions: [] }, + ] satisfies ConnectorDescriptor[]), getFlowRuntimeStates: vi.fn().mockReturnValue([ { name: 'flow_a', enabled: true, bound: true }, { name: 'flow_b', enabled: false, bound: false }, @@ -510,6 +518,25 @@ describe('HttpDispatcher', () => { expect(result.response?.body?.data?.connectors[0].name).toBe('pg'); }); + // [#4127] The route serves the WHOLE descriptor. `origin` and `state` + // are what the designer reads to distinguish a live declarative + // instance from a plugin connector, and a dispatchable one from a + // degraded one (ADR-0097 §4, #3017) — while the contract did not + // declare the method, nothing pinned that they survive the hop. + it('should preserve origin / state / degradedReason on GET /connectors', async () => { + const result = await dispatcher.handleAutomation('connectors', 'GET', {}, { request: {} }); + expect(result.handled).toBe(true); + const byName = Object.fromEntries( + result.response?.body?.data?.connectors.map((c: ConnectorDescriptor) => [c.name, c]), + ); + expect(byName.rest).toMatchObject({ origin: 'plugin', state: 'ready' }); + expect(byName.pg).toMatchObject({ + origin: 'declarative', + state: 'degraded', + degradedReason: 'upstream unreachable', + }); + }); + it('should return an empty registry when the service lacks getConnectorDescriptors', async () => { delete mockAutomationService.getConnectorDescriptors; const result = await dispatcher.handleAutomation('connectors', 'GET', {}, { request: {} }); diff --git a/packages/services/service-automation/src/engine.ts b/packages/services/service-automation/src/engine.ts index 12cf222c49..7f80bd2004 100644 --- a/packages/services/service-automation/src/engine.ts +++ b/packages/services/service-automation/src/engine.ts @@ -9,7 +9,13 @@ import { FlowSchema, FLOW_STRUCTURAL_NODE_TYPES, validateControlFlow, findRegion import { resolveFlowNodeExpressions } from '@objectstack/spec/automation'; import { applyConversionsToFlow } from '@objectstack/spec'; import type { FlowRegionParsed } from '@objectstack/spec/automation'; -import type { Connector, ConnectorProviderFactory } from '@objectstack/spec/integration'; +import type { + Connector, + ConnectorProviderFactory, + ConnectorOrigin, + ConnectorState, + ConnectorDescriptor, +} from '@objectstack/spec/integration'; import { ConnectorSchema } from '@objectstack/spec/integration'; // Static import (not a lazy `require`): the engine ships as ESM ("type":"module"), // where a CommonJS `require('@objectstack/formula')` resolves to tsup's throwing @@ -210,25 +216,15 @@ export type ConnectorActionHandler = ( ctx: ConnectorActionContext, ) => Promise>; -/** - * How a registered connector reached the engine (ADR-0097 §4). `plugin` — a - * connector plugin called `registerConnector` directly (ADR-0018 §Addendum). - * `declarative` — the automation service materialized a provider-bound - * `connectors:` stack entry at boot. A name registered under one origin cannot - * be re-registered under the other: that two-sources-of-truth collision is a - * hard error, not a silent replace. - */ -export type ConnectorOrigin = 'plugin' | 'declarative'; - -/** - * Whether a registered connector is dispatchable (#3017). `ready` — the normal - * state: actions and handlers are live. `degraded` — a declarative instance - * whose provider factory could not reach its upstream (e.g. an MCP server was - * unreachable at boot): it is registered so `GET /connectors` shows it honestly - * instead of it silently missing, but it exposes no actions and every dispatch - * fails with a clear error until the materializer's retry succeeds. - */ -export type ConnectorState = 'ready' | 'degraded'; +// `ConnectorOrigin` / `ConnectorState` / `ConnectorDescriptor` / +// `ConnectorActionDescriptor` are declared in `@objectstack/spec/integration` +// (imported above, re-exported from this package's index). [#4127] They used to +// be declared HERE — which put the return type of `IAutomationService`'s +// `getConnectorDescriptors` inside one implementation of that contract, so the +// contract could not name the method and the dispatcher route serving it had to +// duck-type it. Same reason the provider contract lives in the spec: a +// connector plugin or a designer client speaks about registered connectors +// without importing this engine. /** * A connector registered on the engine: its validated {@link Connector} @@ -348,51 +344,6 @@ export type FlowRecordExpander = ( runContext: AutomationContext, ) => Promise | undefined> | Record | undefined; -/** - * A designer-facing view of one connector action — identity + its JSON-Schema - * input/output. The runtime handler is intentionally omitted; this is metadata. - */ -export interface ConnectorActionDescriptor { - readonly key: string; - readonly label: string; - readonly description?: string; - readonly inputSchema?: Record; - readonly outputSchema?: Record; -} - -/** - * A designer-facing descriptor for a registered connector: its identity plus - * the actions it exposes. Served by `GET /api/v1/automation/connectors` so the - * flow designer can populate the `connector_action` node's connector → action - * → input pickers (ADR-0018 §Addendum, ADR-0022). Mirrors `ActionDescriptor`'s - * role for node types, but for the connector registry. - */ -export interface ConnectorDescriptor { - readonly name: string; - readonly label: string; - readonly type: string; - readonly description?: string; - readonly icon?: string; - readonly actions: ConnectorActionDescriptor[]; - /** - * How the connector reached the registry (ADR-0097 §4): `plugin` — registered - * by a connector plugin via `registerConnector`; `declarative` — materialized - * from a provider-bound `connectors:` stack entry at boot. Lets a designer - * distinguish a live declarative instance from a plugin connector (and both - * from an inert catalog descriptor, which never reaches this list). - */ - readonly origin: ConnectorOrigin; - /** - * Dispatchability (#3017): `ready` — actions are live; `degraded` — the - * instance's upstream was unreachable when the provider factory ran, so it - * currently exposes no actions and cannot dispatch. The platform retries - * degraded instances automatically; `degradedReason` says what failed. - */ - readonly state: ConnectorState; - /** Why the connector is degraded — present only when `state` is `degraded`. */ - readonly degradedReason?: string; -} - // ─── Core Automation Engine ───────────────────────────────────────── /** diff --git a/packages/services/service-automation/src/index.ts b/packages/services/service-automation/src/index.ts index f93c537dc4..0c29588970 100644 --- a/packages/services/service-automation/src/index.ts +++ b/packages/services/service-automation/src/index.ts @@ -11,23 +11,31 @@ export type { ConnectorActionHandler, ConnectorActionContext, RegisteredConnector, - ConnectorOrigin, - ConnectorDescriptor, - ConnectorActionDescriptor, SuspendedRun, SuspendedRunStore, RunRecord, StepLogEntry, } from './engine.js'; -// Connector provider contract (ADR-0097) — re-exported from @objectstack/spec so -// hosts/tests can reach it via this package too. Connector plugins should import -// it directly from `@objectstack/spec/integration` (no coupling to this engine). +// Connector provider contract (ADR-0097) and the registry vocabulary that goes +// with it — re-exported from @objectstack/spec so hosts/tests can reach them via +// this package too. Connector plugins should import them directly from +// `@objectstack/spec/integration` (no coupling to this engine). +// +// [#4127] The descriptor types moved to the spec: `ConnectorDescriptor` is the +// return type of `IAutomationService.getConnectorDescriptors`, so declaring it +// here left the contract unable to name its own method. Same names, same +// shapes — this re-export keeps `@objectstack/service-automation` importers +// working unchanged. export type { ConnectorProviderFactory, ConnectorProviderContext, ConnectorMaterialization, ConnectorMaterializationHandler, + ConnectorOrigin, + ConnectorState, + ConnectorDescriptor, + ConnectorActionDescriptor, } from '@objectstack/spec/integration'; // Durable suspended-run persistence (ADR-0019). The in-memory store is the diff --git a/packages/spec/src/contracts/automation-service.test.ts b/packages/spec/src/contracts/automation-service.test.ts index 59be450706..92aaf7d598 100644 --- a/packages/spec/src/contracts/automation-service.test.ts +++ b/packages/spec/src/contracts/automation-service.test.ts @@ -2,6 +2,7 @@ import { describe, it, expect } from 'vitest'; import type { IAutomationService, AutomationResult } from './automation-service'; import type { FlowParsed } from '../automation/flow.zod'; import type { ExecutionLog } from '../automation/execution.zod'; +import type { ConnectorDescriptor } from '../integration/connector-descriptor'; describe('Automation Service Contract', () => { it('should allow a minimal IAutomationService implementation with required methods', () => { @@ -167,4 +168,57 @@ describe('Automation Service Contract', () => { await service.toggleFlow!('test_flow', true); expect(flowEnabled).toBe(true); }); + + // [#4127] `getConnectorDescriptors` is the sibling of `getActionDescriptors` + // — the other half of the flow designer's `connector_action` pickers — and + // was the last of the four dispatcher routes calling a method the contract + // did not declare. Typing the return value here is the assertion: the + // descriptor must carry `origin` and `state`, so a shape that omits them no + // longer compiles anywhere the contract is honoured. + it('should return typed ConnectorDescriptor[] from getConnectorDescriptors', () => { + const service: IAutomationService = { + execute: async () => ({ success: true }), + listFlows: async () => [], + getConnectorDescriptors: (): ConnectorDescriptor[] => [ + { + name: 'billing', + label: 'Billing', + type: 'api', + origin: 'declarative', + state: 'ready', + actions: [{ key: 'request', label: 'Request', inputSchema: { type: 'object' } }], + }, + { + name: 'gh_mcp', + label: 'GitHub MCP', + type: 'api', + origin: 'declarative', + state: 'degraded', + degradedReason: 'MCP server unreachable at boot', + actions: [], + }, + ], + }; + + const descriptors = service.getConnectorDescriptors!(); + expect(descriptors.map((d) => d.name)).toEqual(['billing', 'gh_mcp']); + expect(descriptors[0].actions[0].key).toBe('request'); + // A degraded instance is listed rather than hidden (#3017), exposes no + // actions, and says why. + expect(descriptors[1].state).toBe('degraded'); + expect(descriptors[1].actions).toEqual([]); + expect(descriptors[1].degradedReason).toBe('MCP server unreachable at boot'); + }); + + // The registry is a flow-engine capability, not a property of every + // automation slot: a script-runner implementation legitimately omits it, and + // `GET /automation/connectors` answers an empty registry rather than 404. + it('should allow an implementation that omits getConnectorDescriptors', () => { + const service: IAutomationService = { + execute: async () => ({ success: true }), + listFlows: async () => [], + }; + + expect(service.getConnectorDescriptors).toBeUndefined(); + }); }); diff --git a/packages/spec/src/contracts/automation-service.ts b/packages/spec/src/contracts/automation-service.ts index fb3c2bd1d0..170d1858b5 100644 --- a/packages/spec/src/contracts/automation-service.ts +++ b/packages/spec/src/contracts/automation-service.ts @@ -16,6 +16,7 @@ import type { FlowParsed } from '../automation/flow.zod'; import type { ExecutionLog } from '../automation/execution.zod'; import type { ActionDescriptor } from '../automation/node-executor.zod'; +import type { ConnectorDescriptor } from '../integration/connector-descriptor'; /** * Context passed to a flow/script execution @@ -346,6 +347,25 @@ export interface IAutomationService { */ getActionDescriptors?(): ActionDescriptor[]; + /** + * The connector registry, as designer-facing descriptors (ADR-0022). + * + * [#4127] Declared because `GET /automation/connectors` already called it — + * the sibling of {@link getActionDescriptors}, which the contract HAS + * declared since ADR-0018, serving the same designer with the other half of + * the `connector_action` node's pickers (node type ← actions, connector / + * action / input ← this). Undeclared, the route had to probe for the method + * and then re-type its own result as `any` to filter on `type`. + * + * Optional for the same reason `getActionDescriptors` is: a connector + * registry is a capability of the flow-engine implementation, not of every + * automation slot — a script-runner implementation of this contract has no + * connectors to describe, and answers an empty registry rather than 404. + * + * @returns One entry per registered connector; empty when none are registered + */ + getConnectorDescriptors?(): ConnectorDescriptor[]; + /** * Per-flow deployment + binding state, for operator surfaces. * diff --git a/packages/spec/src/integration/connector-descriptor.ts b/packages/spec/src/integration/connector-descriptor.ts new file mode 100644 index 0000000000..b18e109588 --- /dev/null +++ b/packages/spec/src/integration/connector-descriptor.ts @@ -0,0 +1,90 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * Connector **registry** vocabulary — how a connector reached the registry, + * whether it can dispatch, and the designer-facing view of it that + * `GET /api/v1/automation/connectors` serves (ADR-0022, ADR-0097 §4, #3017). + * + * These sit beside the provider contract (`connector-provider.ts`) for the same + * reason it lives here: they are pure types (no logic — Prime Directive #2), so + * a connector plugin, a designer client, or the HTTP dispatcher can speak about + * registered connectors depending only on `@objectstack/spec`, with no runtime + * coupling to `@objectstack/service-automation`. + * + * [#4127] They were declared in the engine, which put the return type of a + * *service-contract* method inside one implementation of that contract — so the + * contract could not name it, `IAutomationService` never declared + * `getConnectorDescriptors`, and the dispatcher route that serves it had to + * duck-type the method and then re-type the result as `any` to filter on + * `type`. The declaration moved here; the engine imports it back. + */ + +/** + * How a registered connector reached the engine (ADR-0097 §4). `plugin` — a + * connector plugin called `registerConnector` directly (ADR-0018 §Addendum). + * `declarative` — the automation service materialized a provider-bound + * `connectors:` stack entry at boot. A name registered under one origin cannot + * be re-registered under the other: that two-sources-of-truth collision is a + * hard error, not a silent replace. + */ +export type ConnectorOrigin = 'plugin' | 'declarative'; + +/** + * Whether a registered connector is dispatchable (#3017). `ready` — the normal + * state: actions and handlers are live. `degraded` — a declarative instance + * whose provider factory could not reach its upstream (e.g. an MCP server was + * unreachable at boot): it is registered so `GET /connectors` shows it honestly + * instead of it silently missing, but it exposes no actions and every dispatch + * fails with a clear error until the materializer's retry succeeds. + */ +export type ConnectorState = 'ready' | 'degraded'; + +/** + * A designer-facing view of one connector action — identity + its JSON-Schema + * input/output. The runtime handler is intentionally omitted; this is metadata. + */ +export interface ConnectorActionDescriptor { + readonly key: string; + readonly label: string; + readonly description?: string; + readonly inputSchema?: Record; + readonly outputSchema?: Record; +} + +/** + * A designer-facing descriptor for a registered connector: its identity plus + * the actions it exposes. Served by `GET /api/v1/automation/connectors` so the + * flow designer can populate the `connector_action` node's connector → action + * → input pickers (ADR-0018 §Addendum, ADR-0022). Mirrors `ActionDescriptor`'s + * role for node types, but for the connector registry. + */ +export interface ConnectorDescriptor { + readonly name: string; + readonly label: string; + /** + * The connector's category (`saas`, `database`, `rest`, …) — the same `type` + * the authorable {@link Connector} declares. `GET /connectors?type=` filters + * on it. + */ + readonly type: string; + readonly description?: string; + readonly icon?: string; + readonly actions: ConnectorActionDescriptor[]; + /** + * How the connector reached the registry (ADR-0097 §4): `plugin` — registered + * by a connector plugin via `registerConnector`; `declarative` — materialized + * from a provider-bound `connectors:` stack entry at boot. Lets a designer + * distinguish a live declarative instance from a plugin connector (and both + * from an inert catalog descriptor, which never reaches this list). + */ + readonly origin: ConnectorOrigin; + /** + * Dispatchability (#3017): `ready` — actions are live; `degraded` — the + * instance's upstream was unreachable when the provider factory ran, so it + * currently exposes no actions and cannot dispatch. The platform retries + * degraded instances automatically; `degradedReason` says what failed. + */ + readonly state: ConnectorState; + /** Why the connector is degraded — present only when `state` is `degraded`. */ + readonly degradedReason?: string; +} diff --git a/packages/spec/src/integration/index.ts b/packages/spec/src/integration/index.ts index e0a83a0efe..e6d75ceaf3 100644 --- a/packages/spec/src/integration/index.ts +++ b/packages/spec/src/integration/index.ts @@ -19,6 +19,10 @@ export * from './connector.zod'; export * from './connector-provider'; export * from './connector-provider-errors'; +// Connector registry vocabulary — origin/state and the descriptor +// `GET /automation/connectors` serves (ADR-0022, ADR-0097 §4, #3017) +export * from './connector-descriptor'; + // Connector Templates export * from './connector/saas.zod'; export * from './connector/database.zod'; From c13e71dea3f301c4c80bb5ee84c8ef8b2c110d13 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 30 Jul 2026 12:13:16 +0000 Subject: [PATCH 2/2] chore(spec): record the four connector-descriptor types in the API surface snapshot MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `check:api-surface` gates `@objectstack/spec`'s public exports against a committed snapshot. Moving `ConnectorDescriptor` / `ConnectorActionDescriptor` / `ConnectorOrigin` / `ConnectorState` into `spec/integration` adds four exports to it. The gate's own verdict: 0 breaking (removed/narrowed), 4 added — additions only, which is what a type moving INTO the spec should be. Regenerated with `gen:api-surface`; the diff is exactly those four lines. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_015T5CeitwV1jXLvsL1A84tw --- packages/spec/api-surface.json | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/packages/spec/api-surface.json b/packages/spec/api-surface.json index b9b8ecc407..06a14958a8 100644 --- a/packages/spec/api-surface.json +++ b/packages/spec/api-surface.json @@ -3799,7 +3799,9 @@ "ConflictResolution (type)", "ConflictResolutionSchema (const)", "Connector (type)", + "ConnectorActionDescriptor (interface)", "ConnectorActionSchema (const)", + "ConnectorDescriptor (interface)", "ConnectorErrorCategory (type)", "ConnectorErrorCategorySchema (const)", "ConnectorHealth (type)", @@ -3813,11 +3815,13 @@ "ConnectorInstanceNoAuthSchema (const)", "ConnectorMaterialization (interface)", "ConnectorMaterializationHandler (type)", + "ConnectorOrigin (type)", "ConnectorProviderContext (interface)", "ConnectorProviderFactory (type)", "ConnectorRetryStrategy (type)", "ConnectorRetryStrategySchema (const)", "ConnectorSchema (const)", + "ConnectorState (type)", "ConnectorStatus (type)", "ConnectorStatusSchema (const)", "ConnectorTriggerSchema (const)",