Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
62 changes: 62 additions & 0 deletions .changeset/connector-descriptors-meet-their-contract.md
Original file line number Diff line number Diff line change
@@ -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.
11 changes: 8 additions & 3 deletions packages/runtime/src/domains/automation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<IAutomationService, 'getConnectorDescriptors'>;
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 }) };
}
Expand Down
35 changes: 31 additions & 4 deletions packages/runtime/src/http-dispatcher.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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 },
Expand Down Expand Up @@ -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: {} });
Expand Down
81 changes: 16 additions & 65 deletions packages/services/service-automation/src/engine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -210,25 +216,15 @@ export type ConnectorActionHandler = (
ctx: ConnectorActionContext,
) => Promise<Record<string, unknown>>;

/**
* 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}
Expand Down Expand Up @@ -348,51 +344,6 @@ export type FlowRecordExpander = (
runContext: AutomationContext,
) => Promise<Record<string, unknown> | undefined> | Record<string, unknown> | 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<string, unknown>;
readonly outputSchema?: Record<string, unknown>;
}

/**
* 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 ─────────────────────────────────────────

/**
Expand Down
20 changes: 14 additions & 6 deletions packages/services/service-automation/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 4 additions & 0 deletions packages/spec/api-surface.json
Original file line number Diff line number Diff line change
Expand Up @@ -3799,7 +3799,9 @@
"ConflictResolution (type)",
"ConflictResolutionSchema (const)",
"Connector (type)",
"ConnectorActionDescriptor (interface)",
"ConnectorActionSchema (const)",
"ConnectorDescriptor (interface)",
"ConnectorErrorCategory (type)",
"ConnectorErrorCategorySchema (const)",
"ConnectorHealth (type)",
Expand All @@ -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)",
Expand Down
54 changes: 54 additions & 0 deletions packages/spec/src/contracts/automation-service.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down Expand Up @@ -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();
});
});
Loading
Loading