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
50 changes: 50 additions & 0 deletions .changeset/connector-action-declared-effect.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
---
"@objectstack/spec": minor
"@objectstack/service-automation": minor
---

feat(integration): 连接器动作可以声明它在上游做了什么,`connector_action` 因此能被计数 (#4395)

#4354 给每次流程运行加上了 `selected` / `acted` 汇总,断扫告警是
`selected > 0 AND acted = 0 AND unmeasured = 0`。`connector_action` 当时只能给出三个
答案里最诚实的那个:`ConnectorActionSchema` 只描述动作的**形状**(`key` / `label` /
`inputSchema` / `outputSchema`),对它究竟读还是写只字未提,所以 `crm.push_opportunity`
和 `crm.lookup_account` 在运行时完全无法区分。`acted: 0` 会低报一次 Salesforce 创建,
让每一条健康的连接器扫描都触发告警,操作员很快学会忽略它;`acted: 1` 会高报一次查询,
让告警永不触发——那正是 #4354 要修的原始 bug 换个楼层重演。于是执行器报
`metrics: { unmeasuredEffect: true }`,运行汇总记一笔 `unmeasured`。

诚实,但也是盲区:**任何走连接器的自动化流程都贡献不出任何信号**——既无法证明自己
干过活,也无法在停止干活时被标记出来。

**现在动作可以自己声明。** `ConnectorActionSchema` 新增可选的 `effect`:

```ts
actions: [
{ key: 'push_opportunity', label: 'Push Opportunity', effect: 'write' },
{ key: 'lookup_account', label: 'Lookup Account', effect: 'read' },
{ key: 'legacy_action', label: 'Legacy' }, // 不声明 —— 行为完全不变
]
```

`connector_action` 执行器据此计数:声明 `write` 且派发成功 → `acted: 1`;声明 `read`
→ `acted: 0`(这是一个**真实测得的零**,不是耸肩,所以只做查询的流程重新落入断扫告警
的射程);不声明 → 维持原样 `unmeasuredEffect`。派发失败时,声明 `write` 的动作回落为
不可计数而非零——处理器抛错时上游可能已经写成了,这与 `http` 节点对被拒绝的写请求做的
判断一致;声明 `read` 的动作则仍报 `acted: 0`,它无论如何都不可能改动任何东西。

声明是可选的,这是有意为之:**已有的连接器一个字都不用改,报告的内容与之前逐字相同**,
声明它是纯增益而不是一次迁移。`unmeasuredEffect` 的含义和消费者一个都没变,它现在是
兜底而不是唯一答案。

同一个声明也随 `ConnectorActionDescriptor` 一路送到设计器:`GET /api/v1/automation/connectors`
现在会带上 `effect`,作者在流程设计器里挑动作时,"这个会写" 是关于这次选择的事实。

`effect` 落在**可作者化的** `ConnectorActionSchema` 上,而不只是描述符接口上,因为那是
唯一可能的产地:`AutomationEngine.registerConnector` 存的是 `ConnectorSchema.parse(def)`
的结果,描述符是从这份 def 投影出来的。插件注册路径和 ADR-0097 声明式 materialization
路径都经过这一次 parse,所以两条路都能声明;只加在描述符上则永远无法被任何东西填充
(`ConnectorSchema` 是非 strict 的 `z.object`,改动前作者写下的 `effect` 会被静默丢弃)。

bulk 场景的**计数型**效果(一次动作报告它在上游碰了多少条记录)暂不做,等真实需求。
读/写这一刀才是解开告警的那一刀。
10 changes: 5 additions & 5 deletions content/docs/references/index.mdx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
---
title: Protocol Reference
description: Every schema published by @objectstack/spec — 1609 schemas across 14 protocol modules
description: Every schema published by @objectstack/spec — 1610 schemas across 14 protocol modules
---

{/* ⚠️ AUTO-GENERATED — DO NOT EDIT. Run build-docs.ts to regenerate. Hand-written docs live in the module folders under content/docs/. */}
Expand All @@ -25,15 +25,15 @@ counts are sums of the rows they head. Regenerate with
| [Cloud Protocol](/docs/references/cloud) | 11 | 94 | Environments, packages and versions, marketplace, developer portal, tenancy. |
| [Data Protocol](/docs/references/data) | 29 | 164 | Objects, fields, queries, filters, datasources and drivers — the ObjectQL layer. |
| [Identity Protocol](/docs/references/identity) | 5 | 28 | Users and accounts, organizations, positions, API keys, SCIM provisioning. |
| [Integration Protocol](/docs/references/integration) | 1 | 26 | The single connector protocol (ADR-0097) — catalog descriptors and provider-bound instances. |
| [Integration Protocol](/docs/references/integration) | 1 | 27 | The single connector protocol (ADR-0097) — catalog descriptors and provider-bound instances. |
| [Kernel Protocol](/docs/references/kernel) | 31 | 187 | Plugin lifecycle and manifests, capabilities and security, metadata loading, service registry. |
| [Qa Protocol](/docs/references/qa) | 1 | 8 | Declarative test suites — scenarios, steps, actions and assertions. |
| [Security Protocol](/docs/references/security) | 5 | 27 | Permission sets, row-level security, sharing rules, tenancy posture. |
| [Shared Protocol](/docs/references/shared) | 8 | 31 | Primitives used across every protocol — identifiers, HTTP, expressions, error maps, enums. |
| [Studio Protocol](/docs/references/studio) | 3 | 35 | Studio designer metadata — the authoring surfaces for the protocols above. |
| [System Protocol](/docs/references/system) | 37 | 295 | The runtime environment — logging, jobs, cache, metrics, notifications, i18n and compliance. |
| [UI Protocol](/docs/references/ui) | 17 | 155 | Apps, pages, views, dashboards, reports, actions and themes — the ObjectUI layer. |
| **Total** | **201** | **1609** | 14 protocol modules |
| **Total** | **201** | **1610** | 14 protocol modules |

---

Expand Down Expand Up @@ -203,13 +203,13 @@ Users and accounts, organizations, positions, API keys, SCIM provisioning.

## Integration Protocol

**Source:** `packages/spec/src/integration/` · **Import:** `@objectstack/spec/integration` · **1 page, 26 schemas**
**Source:** `packages/spec/src/integration/` · **Import:** `@objectstack/spec/integration` · **1 page, 27 schemas**

The single connector protocol (ADR-0097) — catalog descriptors and provider-bound instances.

| File | Schemas |
| :--- | :--- |
| [`connector.zod.ts`](/docs/references/integration/connector) | `CircuitBreakerConfig`, `Connector`, `ConnectorAction`, `ConnectorConflictResolution`, `ConnectorErrorCategory`, `ConnectorFieldMapping`, `ConnectorHealth`, `ConnectorInstanceAPIKeyAuth`, `ConnectorInstanceAuth`, `ConnectorInstanceBasicAuth`, `ConnectorInstanceBearerAuth`, `ConnectorInstanceNoAuth`, `ConnectorRetryStrategy`, `ConnectorStatus`, `ConnectorTrigger`, `ConnectorType`, `DataSyncConfig`, `DeclarativeConnectorEntry`, `ErrorMappingConfig`, `ErrorMappingRule`, `HealthCheckConfig`, `RetryConfig`, `SyncStrategy`, `WebhookConfig`, `WebhookEvent`, `WebhookSignatureAlgorithm` |
| [`connector.zod.ts`](/docs/references/integration/connector) | `CircuitBreakerConfig`, `Connector`, `ConnectorAction`, `ConnectorActionEffect`, `ConnectorConflictResolution`, `ConnectorErrorCategory`, `ConnectorFieldMapping`, `ConnectorHealth`, `ConnectorInstanceAPIKeyAuth`, `ConnectorInstanceAuth`, `ConnectorInstanceBasicAuth`, `ConnectorInstanceBearerAuth`, `ConnectorInstanceNoAuth`, `ConnectorRetryStrategy`, `ConnectorStatus`, `ConnectorTrigger`, `ConnectorType`, `DataSyncConfig`, `DeclarativeConnectorEntry`, `ErrorMappingConfig`, `ErrorMappingRule`, `HealthCheckConfig`, `RetryConfig`, `SyncStrategy`, `WebhookConfig`, `WebhookEvent`, `WebhookSignatureAlgorithm` |

---

Expand Down
17 changes: 15 additions & 2 deletions content/docs/references/integration/connector.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -134,8 +134,8 @@ a dead end of the same class in #4738.)
## TypeScript Usage

```typescript
import { CircuitBreakerConfigSchema, ConnectorSchema, ConnectorActionSchema, ConnectorConflictResolutionSchema, ConnectorErrorCategorySchema, ConnectorFieldMappingSchema, ConnectorHealthSchema, ConnectorInstanceAPIKeyAuthSchema, ConnectorInstanceAuthSchema, ConnectorInstanceBasicAuthSchema, ConnectorInstanceBearerAuthSchema, ConnectorInstanceNoAuthSchema, ConnectorRetryStrategySchema, ConnectorStatusSchema, ConnectorTriggerSchema, ConnectorTypeSchema, DataSyncConfigSchema, DeclarativeConnectorEntrySchema, ErrorMappingConfigSchema, ErrorMappingRuleSchema, HealthCheckConfigSchema, RetryConfigSchema, SyncStrategySchema, WebhookConfigSchema, WebhookEventSchema, WebhookSignatureAlgorithmSchema } from '@objectstack/spec/integration';
import type { CircuitBreakerConfig, Connector, ConnectorConflictResolution, ConnectorErrorCategory, ConnectorFieldMapping, ConnectorHealth, ConnectorInstanceAuth, ConnectorRetryStrategy, ConnectorStatus, ConnectorType, DataSyncConfig, DeclarativeConnectorEntry, ErrorMappingConfig, ErrorMappingRule, HealthCheckConfig, RetryConfig, SyncStrategy, WebhookConfig, WebhookEvent, WebhookSignatureAlgorithm } from '@objectstack/spec/integration';
import { CircuitBreakerConfigSchema, ConnectorSchema, ConnectorActionSchema, ConnectorActionEffectSchema, ConnectorConflictResolutionSchema, ConnectorErrorCategorySchema, ConnectorFieldMappingSchema, ConnectorHealthSchema, ConnectorInstanceAPIKeyAuthSchema, ConnectorInstanceAuthSchema, ConnectorInstanceBasicAuthSchema, ConnectorInstanceBearerAuthSchema, ConnectorInstanceNoAuthSchema, ConnectorRetryStrategySchema, ConnectorStatusSchema, ConnectorTriggerSchema, ConnectorTypeSchema, DataSyncConfigSchema, DeclarativeConnectorEntrySchema, ErrorMappingConfigSchema, ErrorMappingRuleSchema, HealthCheckConfigSchema, RetryConfigSchema, SyncStrategySchema, WebhookConfigSchema, WebhookEventSchema, WebhookSignatureAlgorithmSchema } from '@objectstack/spec/integration';
import type { CircuitBreakerConfig, Connector, ConnectorActionEffect, ConnectorConflictResolution, ConnectorErrorCategory, ConnectorFieldMapping, ConnectorHealth, ConnectorInstanceAuth, ConnectorRetryStrategy, ConnectorStatus, ConnectorType, DataSyncConfig, DeclarativeConnectorEntry, ErrorMappingConfig, ErrorMappingRule, HealthCheckConfig, RetryConfig, SyncStrategy, WebhookConfig, WebhookEvent, WebhookSignatureAlgorithm } from '@objectstack/spec/integration';

// Validate data
const result = CircuitBreakerConfigSchema.parse(data);
Expand Down Expand Up @@ -205,6 +205,19 @@ Circuit breaker configuration
| **description** | `string` | optional | |
| **inputSchema** | `Record<string, any>` | optional | Input parameters schema (JSON Schema) |
| **outputSchema** | `Record<string, any>` | optional | Output schema (JSON Schema) |
| **effect** | `Enum<'read' \| 'write'>` | optional | What the action does upstream: 'read' never mutates (reports acted:0); 'write' does (a successful dispatch reports acted:1). Omit when the effect is not knowable — the step is then reported as unmeasured, not as zero |


---

## ConnectorActionEffect

What the action does upstream: 'read' never mutates (reports acted:0); 'write' does (a successful dispatch reports acted:1). Omit when the effect is not knowable — the step is then reported as unmeasured, not as zero

### Allowed Values

* `read`
* `write`


---
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -223,6 +223,153 @@ describe('connector_action (baseline node)', () => {
});
});

// ─── Declared upstream effect (#4395) ────────────────────────────────

/**
* The three answers a `connector_action` step can give, each driven through the
* REAL executor: register a connector whose action declares (or omits) `effect`,
* run a one-node flow, and read #4354's run summary — the actual consumer of
* these metrics, and what the broken-sweep alert
* (`selected > 0 AND acted = 0 AND unmeasured = 0`) queries.
*
* A one-node flow makes the summary fully discriminating:
* declared write, dispatched → acted 1, unmeasured 0
* declared read → acted 0, unmeasured 0
* undeclared → acted 0, unmeasured 1
*/
describe('connector_action declared effect (#4395)', () => {
/** A connector whose single `run` action declares `effect` (or omits it). */
function connectorDeclaring(effect?: 'read' | 'write'): Connector {
return {
name: 'crm',
label: 'CRM',
type: 'saas',
authentication: { type: 'none' },
actions: [{ key: 'run', label: 'Run', ...(effect ? { effect } : {}) }],
} as Connector;
}

/** One-node flow dispatching `crm.run`. */
function registerCallerFlow(engine: AutomationEngine): void {
engine.registerFlow('caller', {
name: 'caller',
label: 'Caller',
type: 'autolaunched',
nodes: [
{ id: 'start', type: 'start', label: 'Start' },
{
id: 'call',
type: 'connector_action',
label: 'Call',
connectorConfig: { connectorId: 'crm', actionId: 'run', input: {} },
},
{ id: 'end', type: 'end', label: 'End' },
],
edges: [
{ id: 'e1', source: 'start', target: 'call' },
{ id: 'e2', source: 'call', target: 'end' },
],
});
}

async function runWith(
effect: 'read' | 'write' | undefined,
handler: () => Promise<Record<string, unknown>>,
) {
const engine = new AutomationEngine(createTestLogger());
registerConnectorNodes(engine, createCtx());
engine.registerConnector(connectorDeclaring(effect), { run: handler });
registerCallerFlow(engine);
return engine.execute('caller');
}

const ok = async () => ({ id: 'ext_1' });
const boom = async () => { throw new Error('upstream refused'); };

it('declared write + successful dispatch → acted: 1 (the sweep can prove it worked)', async () => {
const result = await runWith('write', ok);
expect(result.success).toBe(true);
expect(result.summary).toMatchObject({ acted: 1, unmeasured: 0 });
});

it('declared read → acted: 0, and a REAL zero (unmeasured stays 0)', async () => {
const result = await runWith('read', ok);
expect(result.success).toBe(true);
// The distinction that matters: `acted: 0` here is a measurement, not a
// shrug — so a flow whose only action is a lookup is correctly eligible
// for the broken-sweep alert instead of hiding behind `unmeasured`.
expect(result.summary).toMatchObject({ acted: 0, unmeasured: 0 });
});

it('undeclared → unchanged pre-#4395 behaviour: unmeasured, never acted: 0', async () => {
const result = await runWith(undefined, ok);
expect(result.success).toBe(true);
expect(result.summary).toMatchObject({ acted: 0, unmeasured: 1 });
});

it('declared write whose dispatch FAILED is uncountable, not zero', async () => {
// The handler threw, but the upstream may already have been reached —
// same call the `http` node makes for a rejected mutating request.
const result = await runWith('write', boom);
expect(result.success).toBe(false);
expect(result.summary).toMatchObject({ acted: 0, unmeasured: 1 });
});

it('declared read that failed still reports acted: 0 — it could not have mutated', async () => {
const result = await runWith('read', boom);
expect(result.success).toBe(false);
expect(result.summary).toMatchObject({ acted: 0, unmeasured: 0 });
});

it('resolves the declaration per ACTION, not per connector', async () => {
const engine = new AutomationEngine(createTestLogger());
registerConnectorNodes(engine, createCtx());
engine.registerConnector(
{
name: 'crm',
label: 'CRM',
type: 'saas',
authentication: { type: 'none' },
actions: [
{ key: 'push', label: 'Push', effect: 'write' },
{ key: 'lookup', label: 'Lookup', effect: 'read' },
{ key: 'legacy', label: 'Legacy' },
],
} as Connector,
{ push: ok, lookup: ok, legacy: ok },
);
expect(engine.resolveConnectorActionEffect('crm', 'push')).toBe('write');
expect(engine.resolveConnectorActionEffect('crm', 'lookup')).toBe('read');
expect(engine.resolveConnectorActionEffect('crm', 'legacy')).toBeUndefined();
// Unknown connector / unknown action are the same undeclared answer,
// never a throw: the executor already refuses those with its own error.
expect(engine.resolveConnectorActionEffect('crm', 'ghost')).toBeUndefined();
expect(engine.resolveConnectorActionEffect('ghost', 'push')).toBeUndefined();
});

it('serves the declaration to the designer through GET /connectors', async () => {
const engine = new AutomationEngine(createTestLogger());
engine.registerConnector(
{
name: 'crm',
label: 'CRM',
type: 'saas',
authentication: { type: 'none' },
actions: [
{ key: 'push', label: 'Push', effect: 'write' },
{ key: 'legacy', label: 'Legacy' },
],
} as Connector,
{ push: ok, legacy: ok },
);
const [descriptor] = engine.getConnectorDescriptors();
expect(descriptor.actions.map((a) => [a.key, a.effect])).toEqual([
['push', 'write'],
['legacy', undefined],
]);
});
});

// ─── Engine connector registry ───────────────────────────────────────

describe('AutomationEngine connector registry', () => {
Expand Down
Loading
Loading