Repository navigation
feat(service-automation): declared connector instances resolve by (package, name) through the shared resolver - #31725
Conversation
…rough the package-scoped resolver (ADR-0150 E5) Claude-Session: https://claude.ai/code/session_01ADHkmR4astMzES5UkW47jW Co-authored-by: Claude <noreply@anthropic.com>
…ectors and email templates Claude-Session: https://claude.ai/code/session_01ADHkmR4astMzES5UkW47jW Co-authored-by: Claude <noreply@anthropic.com>
…tors and email templates Claude-Session: https://claude.ai/code/session_01ADHkmR4astMzES5UkW47jW Co-authored-by: Claude <noreply@anthropic.com>
The webhook and email template readers leave this change: the webhook half waits on a ruling about names two packages held before phase 1, and the email half waits on the resolver counting a dependent's added locale as a second holder. Both stay recoverable from the previous commit. Claude-Session: https://claude.ai/code/session_01ADHkmR4astMzES5UkW47jW Co-authored-by: Claude <noreply@anthropic.com>
📓 Docs Drift CheckThis PR changes 1 package(s): 2 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
⛔ 1 release-owned page(s) also name something this change touched. These are read-only:
What this run could not see
Coarse fallback — 10 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 579bdb897a20b4dad1fce191865e535064ebfaa4 && git checkout 579bdb897a20b4dad1fce191865e535064ebfaa4
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 82a3ed2e861d260fdd65517e6b17f03c42eb90c2 785a7428c169810953a4d75e71f91e848409c6d4 && git checkout -B drift-repro 82a3ed2e861d260fdd65517e6b17f03c42eb90c2 && git merge --no-ff 785a7428c169810953a4d75e71f91e848409c6d4
node scripts/docs-audit/affected-docs.mjs --json 82a3ed2e861d260fdd65517e6b17f03c42eb90c2
|
|
epic:#31527#1 · os-steve · session_01ADHkmP7o3uDTti21wLTfPf Epic red-line review: E5 #31677, connectors part, head 47f5d0c · 2026-11-09T00:18Z Verdict: PASS, with one fix to make before landing. The fix is a comment move, not a red-line issue, and needs no re-review.
Fix before landing: the new The two points seat 3 raised:
Still waiting on other cards: webhooks wait for the Q1 Seat 3 accepts and lands this after the fix and green CI. It is not a governed surface. |
…wn method Claude-Session: https://claude.ai/code/session_01ADHkmR4astMzES5UkW47jW Co-authored-by: Claude <noreply@anthropic.com>
|
domain:server#3 · os-steve · session_01ADHkmQXLfvyB5R53VDm3Ka 席位核对 · ACCEPT · #31725 (Part of #31677, ADR-0150 E5 connectors) · 2026-11-09T00:41ZHead-sha:
|
⛔ merge queue 构建失败 — 先分诊,再决定要不要重排队列构建 42541734246 红了。队列跑的是全量套件(PR 侧 CI 只跑 affected 子集), 分类: 失败的 job(日志抽取,best effort):
跨 PR 相同签名(24h,按失败测试文件聚合):
历史信号:
分诊清单:
Generated by Claude Code · merge-queue-triage workflow (#4859) |
|
domain:server#3 · os-steve · session_01ADHkmQXLfvyB5R53VDm3Ka Re-enqueue · #31725 · head
|
… the calling flow's package (objectstack-ai#31863) <!-- claude-projects-attribution: {"github_login":"os-steve"} --> _Requested by **Steve** · [project thread](https://claude.ai/code/project/chan_01FdL1XeUyvVNxLreh3F4Qi9?thread=cmsg_01FdL1XeUyvVNxLreh3F4Qi9B9qYj7GTyDGfaMgXwXx7rZ)_ Fixes objectstack-ai#31677 Clause-②: no Metadata-protocol: none Narrowing: a `connector_action` in a packaged flow whose `connectorConfig.connectorId` does not resolve for the flow's package is refused, naming every candidate, instead of dispatching to whatever instance the engine holds under the name. Three sub-cases: the only holder is outside the flow package's declared closure (the closure-outside sub-case of objectstack-ai#31527 comment 6728487836, a sibling package in the same artifact included); the only instance is environment-authored (ADR-0150 D3 (a)); two packages in the closure hold the name. First-party count: 0 (every first-party packaged flow names its own package's instance or a connector a plugin registers in code; HotCRM 6961ab1, ADR-0150's census). This closes card objectstack-ai#31677 (ADR-0150 child E5, epic objectstack-ai#31527): it is the card's last PR. Connectors landed in objectstack-ai#31725, webhooks in objectstack-ai#31744, email templates in objectstack-ai#31825. This part is the remainder those PRs named: `connector_action` resolving `connectorConfig.connectorId` from the calling flow's package (seat answer 6728849524, Q3), now that E1 objectstack-ai#31738 gives a run its flow's package. **Epic review R1 (6735080780), fixed here:** the caller package now follows the body the engine ARMS under the flow's name, not the loader's set by bare name. An environment override of P's flow (a package-less stored row, or P's declared `.custom` override) names its references with no package, per ADR-0150 D3's overlay paragraph. That corrects E1's `subflow`/`map` reader too, back to main's answer before E1 for overrides. It is a loosening, not a new narrowing. Names were accepted by seat 3 (6735158321). Card thread `domain:server#3` · `os-steve` · `session_01ADHkmR4astMzES5UkW47jW`; claim 6728454873, file-surface amendments 6728498905 and 6728849524. ## 维护者速读 - 改了什么:flow 里的 `connector_action` 按名字找连接器实例前,先用 R 的共享解析器 `resolvePackageScopedName`,以「这个 flow 当前生效的定义所属的软件包」为调用方去问;解析器拒绝时节点失败并列出全部候选。引擎在每次注册 flow 时记下生效定义的来源(包本身、`.custom` 覆盖、环境自建),只有包本身的定义才以该包为调用方;覆盖和环境自建一律按无包调用方解析(E1 的 subflow 也一并纠正)。 - 为什么改:ADR-0150 把连接器改为按(软件包, 名称)寻址;以前包里的 flow 会派发到引擎里同名的任何实例,哪怕它属于闭包外的包或只是环境自建。 - 风险与回滚:只影响「包内 flow 指向自己够不着的已声明连接器」的部署:只有闭包外的包持有、只有环境自建、或闭包内两个包同名。第一方计数 0。环境自建的 flow、代码注册的连接器(slack、rest 等)照常派发。回滚即还原本 PR。 - 先看:`engine.ts` 的 `armedFlowPackage` 和 `registerFlow` 的 `armedFrom`,`flow-precedence.ts` 里识别协议视图下发的覆盖文档那一段,再看 `connectorNameRefusal`。 - 两分钟验证:`pnpm --filter @objectstack/service-automation exec vitest run src/flow-package-scoped-resolution.test.ts -t "connector_action|environment override"`,预期 17 条通过:连接器的 8 条,加上三种覆盖形态各自的 connectorId 与 subflow 都按无包解析(6 条)、包本身定义的两条对照、写入口的一条。 - 需要你做的:无,席位落地。本 PR 合并后 objectstack-ai#31677 关闭。 ## Step 0: where the flow's package comes from | What | Where on `main` | Used here as | |:--|:--|:--| | The run's flow name | `AutomationContext.flowName`, set for every run `execute` starts | the key | | Which package ships that flow | `AutomationEngine.packagedFlowOwner(name)`, fed by `setPackagedFlowSource(packagedFlowReader(ctx))` (E1 objectstack-ai#31738; the protocol's `packagedArtifactOwner`) | the caller package | | Both together | private `flowCallerPackage(context)` (E1): `context.flowName` → `packagedFlowOwner`, `null` for an environment-authored flow or a call outside a run | **corrected (R1)**: `context.flowName` → `armedFlowPackage` | | Which body is armed under the name | `resolveFlowPrecedence` → `classifyContender` at the boot pull, the `kernel:ready` bind, the `metadata:reloaded` re-sync and the mutation sync; the class was dropped after the pick, and a single-contender name was never classified | each winner carries its `contender`, recorded at `registerFlow` | Measured on the real composition (`flow-declared-override-first-boot-pull.integration.test.ts`: ObjectKernel, ObjectQLPlugin over driver-sql/better-sqlite3, two cold boots): at the boot pull the declared override arrives as a stored row carrying its binding, so `classifyContender` already answers `override`. At `kernel:ready` the protocol's view serves it with no binding, stamped `_packageId` = the base beside `overrides.package` = the base, and `isCodeArtifactBody` answered **`package`**. That is the stamp trap; the classifier now recognises that shape. ## What changes - `engine.ts`: a `ConnectorNameResolver` type, `setConnectorNameResolver`, and `connectorNameRefusal(connectorId, context)`. It asks the resolver for the flow's package and returns the refusal when there is one. A caller with no package, a name the registry holds nothing under (a connector a plugin registers in code), or a host with no resolver returns `undefined` and dispatches by name as before. - `builtin/connector-nodes.ts`: `connector_action` checks `connectorNameRefusal` before `resolveConnectorAction` and fails the node with the resolver's message, prefixed with the node id. - `connector-package-scope.ts`: `connectorNameResolver(ctx)`, the ONE resolver (`resolvePackageScopedName`, type `connector`) over the registry ObjectQL serves, read at question time like E1's `flowNameResolver`. - `plugin.ts`: attaches it at `init()`, beside `setFlowNameResolver`. R1, the shared reader: - `engine.ts`: `registerFlow(name, definition, armedFrom?: FlowContender)` records the arm in a `WeakMap` keyed by the parsed body it stores. With no `armedFrom`, the arm is `runtime` (a write door's body is the caller's own). The exception is a body that IS the parsed object the engine armed (the `/automation` door putting back `held`), which keeps its arm by identity. `armedFlowPackage(name)` (public, read-only) returns `packagedFlowOwner(name)` only when the armed body's arm is `package`, else `undefined`. `flowCallerPackage` reads it. - `flow-precedence.ts`: every winner carries `contender`, single-contender names included. `classifyContender` also answers `override` for the served shape: `_packageId` equal to the owner AND `isServedOverrideRowOf` holding for the row the pair would store it as (the reading `servedOverrideBaseOf` makes, through the exported predicate). A package-less row that declares the same carries no envelope and stays `runtime`. - `plugin.ts`: the four arming steps pass `entry.contender`. Kept for the connector backstop child (ADR-0150 D9), as the review recorded: dispatch is still `resolveConnectorAction(connectorId)` by bare name, which is right only while phase 1's one-holder refusal keeps one instance per name. Why a refusal is the only answer that needs acting on: the engine holds one instance per name, materialized for the no-package answer (the declared-connector reconcile from objectstack-ai#31725 already refuses a name two packages hold). In the registry an environment item exists only for a name no package holds (a bare registration under a package-held name keeps the package holder), so a package caller whose name resolves at all resolves to the instance the engine serves. No second resolver, no first-registered fallback. | Reader on `main`, by symbol | Pick today | Caller package | After this PR | |:--|:--|:--|:--| | `connector_action` executor (`registerConnectorNodes`) → `engine.resolveConnectorAction(connectorId, actionId)` | the engine's one instance under the name | the flow's package (`flowCallerPackage`, E1) | a refusal for that package fails the node naming every candidate; any other answer dispatches as before | ## ADR-0150's four readings - **(a) phase-1 one-holder tests stay green.** The four `bare-name-one-holder*` dogfood files are in the full run below. - **(b) bare-name behaviour unchanged for every name with one holder the caller can reach.** Pinned: own package, the one closure holder, an environment-authored flow, a code-registered connector. The change is the declared narrowing above, plus R1's loosening for overrides back to main's pre-E1 answer. No URL, MCP or SDK surface moves. - **(c) no migration.** Nothing stored is read or written differently. - **(d)** `Clause-②: no`, `Metadata-protocol: none`; no ADR-0087 entry. ## Tests `service-automation/src/flow-package-scoped-resolution.test.ts`, new describe "ADR-0150 D3 — a connector_action names its connector from the calling flow's package (epic objectstack-ai#31527 child E5)", over a real `SchemaRegistry` with E1's install/ship/author helpers. 8 tests: four dispatch as before (own package, closure holder, environment-authored flow, code-registered connector); red line 1 (holder only outside the closure, refused naming it, nothing dispatched); D3 (a) (environment-only, refused); red line 2 (two closure holders, refused naming both); and a booted `AutomationServicePlugin` on a `LiteKernel` attaching the resolver at init. R1, same file, describe "ADR-0150 D3 — an environment override of a packaged flow names its references with no package". Each body is armed as the plugin arms it (`resolveFlowPrecedence`, then `registerFlow(…, winner.contender)`). There are three override shapes: a package-less row, the `.custom` override as a stored row, and the `.custom` override as the served envelope. Each is pinned for both `connectorId` (an environment-only instance is reached) and `subflow` (an environment-only flow is reached), 6 tests. Two CONTROLs: P's own body (a package arm) still answers P, so the same references are refused. One more pin: a write door's body is `runtime`, and putting back the held body restores its `package` arm. `flow-declared-override-first-boot-pull.integration.test.ts`, new describe on the real composition, 4 cases: the loader's body alone and beside a package-less row answer P; the declared override, alone or beside a row, answers no package. Each is checked at the boot pull and after `kernel:ready`. Changed existing test code: `flow-precedence-loader-set.test.ts` "asks the set once per contested name, and never for an uncontested one" becomes "once per name, contested or not" (see Verification). E1's `ship()` helper now arms with `{ source: 'package', packageId }`, as the plugin does, and the plugin pin arms P's `charge` before asking. Ablations (`scripts/ablation-replace.mjs`, run over both test files, each restored to the HEAD blob, `git diff HEAD` empty after; the suite imports `src`, so no `dist` leg): | Mutation | Result | |:--|:--| | `connectorNameRefusal` returns `undefined` for every answer (engine) | 3 failed: red line 1, D3 (a), red line 2 | | the `connector_action` refusal check removed (node) | 3 failed: the same three | | `setConnectorNameResolver(connectorNameResolver(ctx))` removed (plugin) | 1 failed: the plugin pin, `expected undefined to be 'refused'` | | R1: `flowCallerPackage` back to `packagedFlowOwner(caller)` (main's E1 reader) | 6 failed: every override shape × connectorId and subflow; both CONTROLs stay green | | R1: the served-envelope `override` branch disabled (classifier) | 4 failed: the served-shape connectorId and subflow pins, and the two real-kernel override cases at `kernel:ready` | | R1: the plugin's four `registerFlow(…, entry.contender)` calls without the arm | 2 failed: the real-kernel cases whose loader body must answer P | ## Verification Head c6a0810 (main 2e9121e merged in as 5e37bfd; c6a0810 adds one test-only commit on top): - `@objectstack/service-automation` test exit 0 (267 files, 3518 tests) and typecheck exit 0 at c6a0810, under `os-verify-lock` (one `&&` command, VERDICT command-exit 0). Root `eslint --no-inline-config packages/services/service-automation`: exit 0. - The first local run, at 5e37bfd, had 1 red: `flow-precedence-loader-set.test.ts` pinned "never asks the loader's set for an uncontested name". R1 classifies every name for its arm, so c6a0810 restates that pin as "once per name, an uncontested one included", and adds that the uncontested body's claimed id buys nothing. CI's Test Core 5/8 showed the same 1 red at 5e37bfd. - `turbo run build --filter='@objectstack/dogfood^...'`: 70/70. - Full dogfood run (owed for the declared narrowing), `pnpm --filter @objectstack/dogfood test` under the lock at 5e37bfd: VERDICT command-exit 0, Test Files 441 passed / 3 skipped (444), Tests 3662 passed / 42 skipped (3704). The only change since is that test file. - Derived gates at c6a0810 (the same 73 as at 5e37bfd): 73 derived, all 73 `exit 0`; `--ran`: "73 derived, 73 run, 0 NOT-MEASURED, 0 UNRUN" (a derived zero). - `test:repo` not run locally (CI's `Test Core`); this diff touches no repo-project test. 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01ADHkmR4astMzES5UkW47jW <!-- signed: domain:server#3 · os-steve · session_01ADHkmR4astMzES5UkW47jW --> --------- Co-authored-by: Claude <noreply@anthropic.com>
Requested by Steve · project thread
Part of #31677
Clause-②: no
Metadata-protocol: none
Narrowing: D3 (a) declared by the card, measured 0 here: no reader in this PR has a caller package (first-party package-caller reads wired: 0), so the closure-outside sub-case of #31527 comment 6728487836 measures 0, sibling-in-the-same-artifact included.
Part of epic #31527 (ADR-0150 child E5), connectors only, per seat 3's split on the card (6728849524, epic seat 6728850663). Two remainders of #31677 stay open:
da3bc28eon this branch's history and lands as its own draft PR afterRuled:.<base>.custompair is read by name but never listed or sent, so a send in that locale renders the base's (#28779 follow-up) #28802) as a second holder of the base's template name.Card thread
domain:server#3·os-steve·session_01ADHkmR4astMzES5UkW47jW; claim 6728454873, file-surface amendment 6728498905.维护者速读
resolvePackageScopedName(无包调用方):先环境自建项,再全环境唯一的包持有者,否则拒绝并列出全部候选。@objectstack/objectql从开发依赖改为运行时依赖,无循环。回滚即还原本 PR。packages/services/service-automation/src/connector-package-scope.ts,以及plugin.ts里reconcileDeclaredConnectors开头的拒绝循环。pnpm --filter @objectstack/service-automation exec vitest run src/connector-materialization.test.ts -t "resolves by",预期 2 条通过、其余跳过,其中两个包同名的用例断言启动失败、错误信息点名两个包、工厂从未被调用。What changes
reconcileDeclaredConnectors(service-automation, the declared-instance index the engine'sconnectorsmap is filled from) now asks the ONE package-scoped resolver (resolvePackageScopedName,@objectstack/objectql, R #31637) which declared connector instance a name means, before it materializes anything. The reconcile reads the whole environment's declaration and has no caller package, so it asks as a caller with no package (ADR-0150 D3): the environment's own item, else the unique package holder, else a refusal naming every candidate.main, by symbolreconcileDeclaredConnectorsdesired mapfail()names every candidate (fatal at boot for a host-declared entry, soft for a stored one, as the duplicate check already was)Not touched, and why:
connector_action'sconnectorConfig.connectorIdis the one reference here written inside a package (the flow's).AutomationContext(spec) names no flow package, so the node cannot ask as that package yet; per seat 3's ruling (Q3 A) it stays context-free here and is wired after E1 service-automation: flow index and activation consult on (package, name) (ADR-0150 child E1) #31672. It keeps reading the engine's one-instance-per-name map, which this PR now fills through the resolver.findInertDeclaredConnectors(an audit warning) picks no item.engine.registerConnector) are no registry items; the resolver answers not-found and they keep ADR-0097 §4's one-origin rule.sys_connector_connection.connector_name) are S3 automation stored references gain a package column, backfilled (R7–R11, R14, R20) (ADR-0150 child S3) #31684's.@objectstack/objectqlmoves fromdevDependenciestodependenciesin service-automation (claim amendment 6728498905). No cycle: objectql's workspace closure (deps, devDeps and peers, 14 packages) does not contain service-automation. E1 #31672 makes the same move on the same lines; a trial merge of its branch onto this head is clean, and per the seat E1 lands first and this branch mergesorigin/mainafter it.ADR-0150's four readings
registry-bare-name-one-holder*suites pluspackage-scoped-resolver.test.ts: 187 passed; the fourbare-name-one-holder*dogfood files are in the targeted run below. This PR does not touch objectql.SchemaRegistry(the connector materializes with the holder's definition), and service-automation's full suite stays green. No URL, MCP or SDK surface moves.Clause-②: no,Metadata-protocol: none; no ADR-0087 entry.Tests
service-automation/src/connector-materialization.test.ts, new describe "a declared connector name resolves by (package, name)": one holder materializes; two holders fail the boot naming both, and the provider factory is never called. The harness'sboot()gains an optionalschemaRegistry(a real registry in place of the double). No existing test changed.Ablation (
scripts/ablation-replace.mjs, mutation proved on disk, restored to the HEAD blob,git diff HEADempty after; the suite importssrc, so nodistleg; measured atda3bc28e, whose service-automation tree is byte-identical to this head):refusedConnectorNamesrecords no refusal → 1 failed: the boot still fails on the older duplicate-name check, butexpected '[Automation] duplicate declarative co…' to contain 'connector instance \'billing\' is not…'. The candidates are what the pin adds.Verification (head 47f5d0c)
@objectstack/service-automationtest exit 0 (266 files, 3458 tests); typecheck exit 0; rooteslint --no-inline-config packages/services/service-automationexit 0 (the package has nolintscript). Run underos-verify-lock.turbo run build --filter='@objectstack/dogfood^...'(70/70): 19 files (the four phase-1 bare-name families, email template boot/materialization/locale files, connector install/restart/package-ref files, declarative MCP, webhook materialization), 96 tests passed.dispatch-gates --commandsderived 85 families at 47f5d0c; all 85 run withexit 0;--ran: "85 derived, 85 run, 0 NOT-MEASURED, 0 UNRUN" (a derived zero, every row carrying its exit code).test:reponot run locally (CI'sTest Core); this diff touches no repo-project test.🤖 Generated with Claude Code
https://claude.ai/code/session_01ADHkmR4astMzES5UkW47jW