Skip to content

Commit e8f8f6c

Browse files
qq9340100claude
andauthored
feat(integration): 连接器动作可声明上游效果,connector_action 因此可计数 (#4395) (#6160)
* feat(integration): 连接器动作可声明上游效果,connector_action 因此可计数 (#4395) `ConnectorActionDescriptor` 只描述动作的形状,对它究竟读还是写只字未提,所以 `crm.push_opportunity` 和 `crm.lookup_account` 在运行时无法区分。#4354 的断扫告警 (`selected > 0 AND acted = 0 AND unmeasured = 0`) 因此对所有连接器驱动的流程都是盲区: 执行器只能报 `unmeasuredEffect`,既无法证明干过活,也无法在停止干活时被标记。 `ConnectorActionSchema` 新增可选 `effect: 'read' | 'write'`,`connector_action` 执行器据此三分支计数:声明 write 且派发成功 → acted:1;声明 read → acted:0(真实测得 的零);不声明 → 维持 unmeasuredEffect。派发失败时声明 write 回落为不可计数(上游可能 已写成,与 http 节点对被拒绝的写请求判断一致),声明 read 仍报 acted:0。 `effect` 落在可作者化的 ConnectorActionSchema 而不只是描述符接口上,因为那是唯一可能 的产地:registerConnector 存的是 ConnectorSchema.parse(def),描述符是从这份 def 投影 出来的;只加在描述符上则永远无法被填充(非 strict 的 z.object 会静默丢弃作者写的 effect,已实测)。同一声明经 getConnectorDescriptors 送达设计器。 count 型效果(bulk)不做,待真实需求。 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011M7UwH25Unfi73UHim7ajY * chore(spec): 合并 main 后重生成 docs/authorable-surface 分片 本仓的 regen 合并驱动在 merge 时把生成产物留给合并后重生成,因此这一次 `gen:docs` 吸收的是 main 侧 #5552(`FieldMapping.transform` 退役)的既有后果,不是本 PR 的改动; 本 PR 自己的 `ConnectorAction:effect` 行原样保留。check:generated 十门恢复全绿。 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011M7UwH25Unfi73UHim7ajY * chore(spec): 按 ADR-0122 D5/D6 给 ConnectorActionEffect 归位到同构 pin (#4395) #6072 的防回流门 check:spec-parsed-alias 已落 main,本分支建于其前,新增导出需补齐时序。 `ConnectorActionEffectSchema` 是一个裸 z.enum —— 无 default、无 transform,z.input 与 z.infer 同构 —— 按 D5 判据不配 `XParsed`(那会给作者一个只能选错的同义名),按 D6 改为在 type-alias-convention.pin.test.ts 立编译期断言。与其上方两行的 ConnectorType / ConnectorStatus 是同一类,故并入 integration/connector.zod.ts 分块;编号接文件末尾续号 (IsoNNN 只是唯一名,门读的是 z.input 出现处),避免为补一个空位重排其后 300 余行。 同时把 pin 计数断言 718 → 719 —— 该用例原注释只预期「减一」,这里补记它同样会因新增 同构别名而「加一」。 实测 D6 确实咬得住:临时给该 schema 加 .default('read') 后,check:test-typecheck 报 `src/type-alias-convention.pin.test.ts: 1 type error(s) in a file the ledger does not cover`(注:裸 tsc --noEmit 不覆盖 *.test.ts,证明落在 typecheck 脚本的第二步)。 api-surface/integration.json 一并重生成:regen 合并驱动在 merge 时把该产物留给合并后重生成, 本次补回 #6072 的 10 个 XParsed 条目,本 PR 自己的两个条目原样保留。 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011M7UwH25Unfi73UHim7ajY --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 72b55d3 commit e8f8f6c

15 files changed

Lines changed: 468 additions & 31 deletions

File tree

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
---
2+
"@objectstack/spec": minor
3+
"@objectstack/service-automation": minor
4+
---
5+
6+
feat(integration): 连接器动作可以声明它在上游做了什么,`connector_action` 因此能被计数 (#4395)
7+
8+
#4354 给每次流程运行加上了 `selected` / `acted` 汇总,断扫告警是
9+
`selected > 0 AND acted = 0 AND unmeasured = 0``connector_action` 当时只能给出三个
10+
答案里最诚实的那个:`ConnectorActionSchema` 只描述动作的**形状**`key` / `label` /
11+
`inputSchema` / `outputSchema`),对它究竟读还是写只字未提,所以 `crm.push_opportunity`
12+
`crm.lookup_account` 在运行时完全无法区分。`acted: 0` 会低报一次 Salesforce 创建,
13+
让每一条健康的连接器扫描都触发告警,操作员很快学会忽略它;`acted: 1` 会高报一次查询,
14+
让告警永不触发——那正是 #4354 要修的原始 bug 换个楼层重演。于是执行器报
15+
`metrics: { unmeasuredEffect: true }`,运行汇总记一笔 `unmeasured`
16+
17+
诚实,但也是盲区:**任何走连接器的自动化流程都贡献不出任何信号**——既无法证明自己
18+
干过活,也无法在停止干活时被标记出来。
19+
20+
**现在动作可以自己声明。** `ConnectorActionSchema` 新增可选的 `effect`
21+
22+
```ts
23+
actions: [
24+
{ key: 'push_opportunity', label: 'Push Opportunity', effect: 'write' },
25+
{ key: 'lookup_account', label: 'Lookup Account', effect: 'read' },
26+
{ key: 'legacy_action', label: 'Legacy' }, // 不声明 —— 行为完全不变
27+
]
28+
```
29+
30+
`connector_action` 执行器据此计数:声明 `write` 且派发成功 → `acted: 1`;声明 `read`
31+
`acted: 0`(这是一个**真实测得的零**,不是耸肩,所以只做查询的流程重新落入断扫告警
32+
的射程);不声明 → 维持原样 `unmeasuredEffect`。派发失败时,声明 `write` 的动作回落为
33+
不可计数而非零——处理器抛错时上游可能已经写成了,这与 `http` 节点对被拒绝的写请求做的
34+
判断一致;声明 `read` 的动作则仍报 `acted: 0`,它无论如何都不可能改动任何东西。
35+
36+
声明是可选的,这是有意为之:**已有的连接器一个字都不用改,报告的内容与之前逐字相同**
37+
声明它是纯增益而不是一次迁移。`unmeasuredEffect` 的含义和消费者一个都没变,它现在是
38+
兜底而不是唯一答案。
39+
40+
同一个声明也随 `ConnectorActionDescriptor` 一路送到设计器:`GET /api/v1/automation/connectors`
41+
现在会带上 `effect`,作者在流程设计器里挑动作时,"这个会写" 是关于这次选择的事实。
42+
43+
`effect` 落在**可作者化的** `ConnectorActionSchema` 上,而不只是描述符接口上,因为那是
44+
唯一可能的产地:`AutomationEngine.registerConnector` 存的是 `ConnectorSchema.parse(def)`
45+
的结果,描述符是从这份 def 投影出来的。插件注册路径和 ADR-0097 声明式 materialization
46+
路径都经过这一次 parse,所以两条路都能声明;只加在描述符上则永远无法被任何东西填充
47+
`ConnectorSchema` 是非 strict 的 `z.object`,改动前作者写下的 `effect` 会被静默丢弃)。
48+
49+
bulk 场景的**计数型**效果(一次动作报告它在上游碰了多少条记录)暂不做,等真实需求。
50+
读/写这一刀才是解开告警的那一刀。

content/docs/references/index.mdx

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
---
22
title: Protocol Reference
3-
description: Every schema published by @objectstack/spec — 1609 schemas across 14 protocol modules
3+
description: Every schema published by @objectstack/spec — 1610 schemas across 14 protocol modules
44
---
55

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

3838
---
3939

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

204204
## Integration Protocol
205205

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

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

210210
| File | Schemas |
211211
| :--- | :--- |
212-
| [`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` |
212+
| [`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` |
213213

214214
---
215215

content/docs/references/integration/connector.mdx

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -134,8 +134,8 @@ a dead end of the same class in #4738.)
134134
## TypeScript Usage
135135

136136
```typescript
137-
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';
138-
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';
137+
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';
138+
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';
139139

140140
// Validate data
141141
const result = CircuitBreakerConfigSchema.parse(data);
@@ -205,6 +205,19 @@ Circuit breaker configuration
205205
| **description** | `string` | optional | |
206206
| **inputSchema** | `Record<string, any>` | optional | Input parameters schema (JSON Schema) |
207207
| **outputSchema** | `Record<string, any>` | optional | Output schema (JSON Schema) |
208+
| **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 |
209+
210+
211+
---
212+
213+
## ConnectorActionEffect
214+
215+
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
216+
217+
### Allowed Values
218+
219+
* `read`
220+
* `write`
208221

209222

210223
---

packages/services/service-automation/src/builtin/connector-nodes.test.ts

Lines changed: 147 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -223,6 +223,153 @@ describe('connector_action (baseline node)', () => {
223223
});
224224
});
225225

226+
// ─── Declared upstream effect (#4395) ────────────────────────────────
227+
228+
/**
229+
* The three answers a `connector_action` step can give, each driven through the
230+
* REAL executor: register a connector whose action declares (or omits) `effect`,
231+
* run a one-node flow, and read #4354's run summary — the actual consumer of
232+
* these metrics, and what the broken-sweep alert
233+
* (`selected > 0 AND acted = 0 AND unmeasured = 0`) queries.
234+
*
235+
* A one-node flow makes the summary fully discriminating:
236+
* declared write, dispatched → acted 1, unmeasured 0
237+
* declared read → acted 0, unmeasured 0
238+
* undeclared → acted 0, unmeasured 1
239+
*/
240+
describe('connector_action declared effect (#4395)', () => {
241+
/** A connector whose single `run` action declares `effect` (or omits it). */
242+
function connectorDeclaring(effect?: 'read' | 'write'): Connector {
243+
return {
244+
name: 'crm',
245+
label: 'CRM',
246+
type: 'saas',
247+
authentication: { type: 'none' },
248+
actions: [{ key: 'run', label: 'Run', ...(effect ? { effect } : {}) }],
249+
} as Connector;
250+
}
251+
252+
/** One-node flow dispatching `crm.run`. */
253+
function registerCallerFlow(engine: AutomationEngine): void {
254+
engine.registerFlow('caller', {
255+
name: 'caller',
256+
label: 'Caller',
257+
type: 'autolaunched',
258+
nodes: [
259+
{ id: 'start', type: 'start', label: 'Start' },
260+
{
261+
id: 'call',
262+
type: 'connector_action',
263+
label: 'Call',
264+
connectorConfig: { connectorId: 'crm', actionId: 'run', input: {} },
265+
},
266+
{ id: 'end', type: 'end', label: 'End' },
267+
],
268+
edges: [
269+
{ id: 'e1', source: 'start', target: 'call' },
270+
{ id: 'e2', source: 'call', target: 'end' },
271+
],
272+
});
273+
}
274+
275+
async function runWith(
276+
effect: 'read' | 'write' | undefined,
277+
handler: () => Promise<Record<string, unknown>>,
278+
) {
279+
const engine = new AutomationEngine(createTestLogger());
280+
registerConnectorNodes(engine, createCtx());
281+
engine.registerConnector(connectorDeclaring(effect), { run: handler });
282+
registerCallerFlow(engine);
283+
return engine.execute('caller');
284+
}
285+
286+
const ok = async () => ({ id: 'ext_1' });
287+
const boom = async () => { throw new Error('upstream refused'); };
288+
289+
it('declared write + successful dispatch → acted: 1 (the sweep can prove it worked)', async () => {
290+
const result = await runWith('write', ok);
291+
expect(result.success).toBe(true);
292+
expect(result.summary).toMatchObject({ acted: 1, unmeasured: 0 });
293+
});
294+
295+
it('declared read → acted: 0, and a REAL zero (unmeasured stays 0)', async () => {
296+
const result = await runWith('read', ok);
297+
expect(result.success).toBe(true);
298+
// The distinction that matters: `acted: 0` here is a measurement, not a
299+
// shrug — so a flow whose only action is a lookup is correctly eligible
300+
// for the broken-sweep alert instead of hiding behind `unmeasured`.
301+
expect(result.summary).toMatchObject({ acted: 0, unmeasured: 0 });
302+
});
303+
304+
it('undeclared → unchanged pre-#4395 behaviour: unmeasured, never acted: 0', async () => {
305+
const result = await runWith(undefined, ok);
306+
expect(result.success).toBe(true);
307+
expect(result.summary).toMatchObject({ acted: 0, unmeasured: 1 });
308+
});
309+
310+
it('declared write whose dispatch FAILED is uncountable, not zero', async () => {
311+
// The handler threw, but the upstream may already have been reached —
312+
// same call the `http` node makes for a rejected mutating request.
313+
const result = await runWith('write', boom);
314+
expect(result.success).toBe(false);
315+
expect(result.summary).toMatchObject({ acted: 0, unmeasured: 1 });
316+
});
317+
318+
it('declared read that failed still reports acted: 0 — it could not have mutated', async () => {
319+
const result = await runWith('read', boom);
320+
expect(result.success).toBe(false);
321+
expect(result.summary).toMatchObject({ acted: 0, unmeasured: 0 });
322+
});
323+
324+
it('resolves the declaration per ACTION, not per connector', async () => {
325+
const engine = new AutomationEngine(createTestLogger());
326+
registerConnectorNodes(engine, createCtx());
327+
engine.registerConnector(
328+
{
329+
name: 'crm',
330+
label: 'CRM',
331+
type: 'saas',
332+
authentication: { type: 'none' },
333+
actions: [
334+
{ key: 'push', label: 'Push', effect: 'write' },
335+
{ key: 'lookup', label: 'Lookup', effect: 'read' },
336+
{ key: 'legacy', label: 'Legacy' },
337+
],
338+
} as Connector,
339+
{ push: ok, lookup: ok, legacy: ok },
340+
);
341+
expect(engine.resolveConnectorActionEffect('crm', 'push')).toBe('write');
342+
expect(engine.resolveConnectorActionEffect('crm', 'lookup')).toBe('read');
343+
expect(engine.resolveConnectorActionEffect('crm', 'legacy')).toBeUndefined();
344+
// Unknown connector / unknown action are the same undeclared answer,
345+
// never a throw: the executor already refuses those with its own error.
346+
expect(engine.resolveConnectorActionEffect('crm', 'ghost')).toBeUndefined();
347+
expect(engine.resolveConnectorActionEffect('ghost', 'push')).toBeUndefined();
348+
});
349+
350+
it('serves the declaration to the designer through GET /connectors', async () => {
351+
const engine = new AutomationEngine(createTestLogger());
352+
engine.registerConnector(
353+
{
354+
name: 'crm',
355+
label: 'CRM',
356+
type: 'saas',
357+
authentication: { type: 'none' },
358+
actions: [
359+
{ key: 'push', label: 'Push', effect: 'write' },
360+
{ key: 'legacy', label: 'Legacy' },
361+
],
362+
} as Connector,
363+
{ push: ok, legacy: ok },
364+
);
365+
const [descriptor] = engine.getConnectorDescriptors();
366+
expect(descriptor.actions.map((a) => [a.key, a.effect])).toEqual([
367+
['push', 'write'],
368+
['legacy', undefined],
369+
]);
370+
});
371+
});
372+
226373
// ─── Engine connector registry ───────────────────────────────────────
227374

228375
describe('AutomationEngine connector registry', () => {

0 commit comments

Comments
 (0)