Skip to content

Commit 4f136c5

Browse files
committed
Merge remote-tracking branch 'origin/main' into claude/issue-4657-activation-events-retirement
2 parents 6d3d388 + cb680f2 commit 4f136c5

4 files changed

Lines changed: 236 additions & 30 deletions

File tree

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
---
2+
---
3+
4+
docs(protocol): `protocol/kernel/http-protocol` 的 API Discovery 一节拆成两段式 —— `@objectstack/rest` 服务的 `/api/v1`(与 `/api/v1/discovery`)与 dispatcher 服务的 `/.well-known/objectstack` 各给一份真实响应形状,不再共用一份混合示例。Docs-only;releases nothing.
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
---
2+
---
3+
4+
docs(pm-dispatch): domain 车道协议 —— 按「修复落点的包」划域,支持同仓多 PM 并发 (#4819)
5+
6+
`.claude/skills/pm-dispatch/SKILL.md` 新增「Domain lanes(同仓多 PM 并发)」一节:
7+
锚定规则(每个包恰好属于一个 domain,`domain:*` 标签取**修复落点所在包**的域,分诊时
8+
读代码后打,不从标题词汇猜 —— #4775 的 hook condition 概念属 automation,落点却是
9+
`packages/objectql/src/hook-wrappers.ts`,故归 `domain:engine`)、六域分类表、标签纪律
10+
(打标 ≠ 认领;未打标不得认领)、认领范围(在 #4604 登记 domain 集合)、跨域单与借单
11+
规则、选批时的全局在飞检查,以及合并队列仍是全体共享串行资源的提醒(flaky 税,#4796)。
12+
认领注释模板加「域」「文件面」两行(跨域与借单必填);Multi-repo coordination 规则 4 的
13+
「同队列多 PM 一律禁止」改为「仅在 domain 车道协议生效时允许」,repo 分片阶梯保留,
14+
domain 车道作为第三级。
15+
16+
仅改内部 agent 协议文本,不发布任何包。

.claude/skills/pm-dispatch/SKILL.md

Lines changed: 95 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -102,11 +102,13 @@ known case: accepting a `repo:objectui` PR ⇒ file a `pm:queue` issue in
102102
`objectstack` — "run `pnpm objectui:refresh` and land the console bump",
103103
referencing the merged PR, blocked-by it until it actually merges.
104104

105-
**4. Multiple PM sessions shard by repo — never share one queue.** The
106-
claim protocol makes concurrent PMs *safe*, not *useful*: batch
107-
independence (file-disjointness) is only checked within one PM's view, so
108-
two PMs on the same queue can claim different issues that collide on
109-
shared files, and the merge queue is one lane regardless. Scaling order:
105+
**4. Multiple PM sessions shard by repo; one shared queue only under
106+
domain lanes.** The claim protocol makes concurrent PMs *safe*, not
107+
*useful* on its own: batch independence (file-disjointness) is only checked
108+
within one PM's view, so two PMs on the same queue can claim different
109+
issues that collide on shared files, and the merge queue is one lane
110+
regardless. Making that check **global** is exactly what the next section
111+
does. Scaling order:
110112

111113
1. One PM, bigger batch (`batch:5` is the maintainer's chosen operating
112114
point, riding on the resource discipline above), heavy tasks via
@@ -115,7 +117,13 @@ shared files, and the merge queue is one lane regardless. Scaling order:
115117
repo** as its shard (`/pm-dispatch repo:objectstack-ai/objectui`) —
116118
file universes are disjoint by construction. A sharded PM states its
117119
shard in every claim comment and **never claims outside it**.
118-
3. Multiple PMs on the SAME queue: prohibited — all cost, no throughput.
120+
3. Multiple PMs on the SAME queue: **prohibited unless the Domain-lanes
121+
protocol (next section) is active** — every PM in its own session and
122+
its own container, domain sets registered in the registry issue, label
123+
discipline observed, and the global in-flight check run at every batch
124+
selection. Without that protocol the ban stands as written: all cost,
125+
no throughput, and the collision stays invisible to both PMs until the
126+
merge.
119127

120128
**Shard ownership is registered, never assumed.** A registry issue in the
121129
main backlog (`[PM] 分片分工登记表`) records which session owns which
@@ -159,6 +167,81 @@ gives the maintainer a single view (filter by `repo:*` and `pm:*`). The PM
159167
maintains no tracking state outside GitHub — that invariant is what keeps
160168
the loop resumable and the board honest.
161169

170+
## Domain lanes(同仓多 PM 并发)
171+
172+
Rule 4's ladder ran out at one PM per repo because file-disjointness is only
173+
ever checked inside one PM's own view. Domain lanes are the **third rung**:
174+
one PM's triage verdict is cached as a `domain:*` label every other PM can
175+
read, so batch selection filters at the label layer instead of at the merge.
176+
Premise: each PM is its **own session in its own container** — adding a PM
177+
adds compute, not contention — and collisions are prevented by the
178+
domain→package mapping, not by hoping two PMs pick different work.
179+
180+
**Anchoring rule.** The whole scheme rests on this one sentence:
181+
182+
> Every package belongs to exactly **one** domain; an issue's `domain:*`
183+
> label is the domain of **the package the fix lands in**, decided at triage
184+
> by reading the code — **never guessed from the issue's title vocabulary**.
185+
186+
The counter-example that makes it a rule: #4775 is a hook `condition`, which
187+
reads as automation, but the fix lands in
188+
`packages/objectql/src/hook-wrappers.ts``domain:engine`. Labeling by topic
189+
would have routed it to a different PM than the one already inside that
190+
package — the exact collision lanes exist to prevent. If you cannot say which
191+
file the fix touches, you have not triaged it yet, and it is not labelable.
192+
193+
| 标签 | 包家族 |
194+
|:--|:--|
195+
| `domain:engine` | `packages/objectql``packages/metadata*``packages/platform-objects``packages/core``packages/plugins/driver-*` |
196+
| `domain:services` | `packages/services/*``packages/plugins/plugin-approvals``plugin-webhooks``packages/connectors/*` |
197+
| `domain:identity` | `packages/plugins/plugin-auth``plugin-security``plugin-sharing``plugin-audit` |
198+
| `domain:devx` | `packages/lint``skills/**``content/docs/**``scripts/`(门禁类) |
199+
| `domain:spec` | `packages/spec` 及其生成物(现 spec 车道不变) |
200+
| `domain:cli` | `packages/cli``runtime``verify``qa``types` |
201+
202+
`examples/**` belongs to the subsystem it exercises; anything that fits
203+
nowhere is judged at triage by its principal landing site. A package missing
204+
from the table is classified the first time it is triaged and the table
205+
updated **by PR** — the taxonomy evolves deliberately, never per-claim.
206+
207+
**Label discipline.** `domain:*` is applied during the backlog sweep (round
208+
loop step 0) by whichever PM triages the issue. **Labeling ≠ claiming**: any
209+
PM may label any issue, including ones it will never claim — the label is
210+
shared routing, not a reservation. An **unlabeled issue may not be claimed by
211+
anyone**: triage and label it first, or selection has silently gone back to
212+
happening inside one PM's private view.
213+
214+
**Claim scope.** Each PM session registers its **domain set** in the registry
215+
issue (`[PM] 分片分工登记表`, #4604 — the same registry that records repo
216+
shards) and claims only issues whose label falls inside that set. A set, not
217+
a single domain: lanes are a routing table, not a job title.
218+
219+
**Cross-domain issues.** Prefer the contract-first split of rule 2 — one
220+
sub-issue per domain, each carrying its own `domain:*` label, ordered with
221+
`Blocked-by:`. When a split costs more than it buys, a single PM claims the
222+
whole issue and **declares the full file surface** in its claim comment, so
223+
every other PM's in-flight check can see all of it.
224+
225+
**Borrowing.** An idle PM may claim outside its registered set when all three
226+
hold: (a) that domain's PM has not claimed the issue, (b) the claim comment
227+
declares the file surface, (c) the global in-flight check below passes.
228+
Borrowing is a one-issue exception, not a lane transfer — the registry entry
229+
does not change, so nobody has to guess who owns the domain afterwards.
230+
231+
**Global in-flight check — run it at batch selection (step 3).** List every
232+
`pm:dispatched` issue across the repo, read the file-surface declaration on
233+
each one's latest claim comment, and require your candidates to be disjoint
234+
from all of them. This is step 3's independence test raised from your batch
235+
to the whole repo; skip it and two individually-independent batches are
236+
jointly dependent, which is precisely the failure the same-queue ban was
237+
protecting against.
238+
239+
**The merge queue is still one shared serial resource.** Lanes buy parallel
240+
authorship, not parallel landing: the flaky-test tax (#4796) scales linearly
241+
with the number of PMs, and a red queue blocks every lane at once. Queue
242+
health is therefore a shared duty — a PM that notices a flake fixes or files
243+
it rather than re-queuing past it, whichever lane it came from.
244+
162245
## The round loop
163246

164247
### 0. Backlog sweep — classification is a standing duty, not a request
@@ -280,6 +363,12 @@ execute atomically, in order:
280363
> 会话:`session_<id>`
281364
> 分支:`claude/issue-<n>-<slug>`
282365
> Worktree:`<repo>-issue-<n>`
366+
> 域:`domain:<x>`
367+
> 文件面:`<预计触碰的目录列表>`(越界即停,报告说明)
368+
369+
「文件面」is **required** for cross-domain and borrowed claims and
370+
**recommended** for ordinary same-domain ones — it is the only input
371+
another PM's global in-flight check has to read.
283372
3. **Race check**: assignment is idempotent, so two agents can both
284373
"succeed". Re-read the comments; if an earlier claim comment with a
285374
*different* session ID or branch name exists, you lost — touch nothing of

content/docs/protocol/kernel/http-protocol.mdx

Lines changed: 121 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -19,51 +19,148 @@ The **HTTP API** defines how ObjectStack maps data operations to RESTful HTTP en
1919

2020
## API Discovery
2121

22-
### Discovery Endpoint
22+
Before making any API calls, clients should request a discovery endpoint to learn about
23+
available services. **Two endpoints answer that question, and in a stack that mounts
24+
`@objectstack/rest` they do not return the same shape** — they are built by different
25+
packages. Read the one that matches your composition; do not mix their fields.
2326

24-
Before making any API calls, clients should request the discovery endpoint to learn about available services:
27+
### `GET /api/v1` (and `GET /api/v1/discovery`)
2528

26-
**Request:**
27-
```http
28-
GET /.well-known/objectstack HTTP/1.1
29-
Host: api.acme.com
30-
```
29+
Returns the full discovery manifest. `@objectstack/rest` registers **one handler at both
30+
paths** — the API base path and `<basePath>/discovery` — so the two are the same document,
31+
not a redirect and not two shapes. In a REST-less composition the runtime dispatcher
32+
registers `<basePath>/discovery` as the fallback owner instead, and then serves its own
33+
`/.well-known/objectstack` payload there (see below); when `@objectstack/rest` is mounted
34+
the dispatcher cedes the route to it, so a single owner answers it (ADR-0076 D11).
3135

32-
`/.well-known/objectstack` and the versioned `/api/v1/discovery` route both return the
33-
discovery document directly — there is no HTTP redirect between them:
36+
**Request:**
3437
```http
3538
GET /api/v1/discovery HTTP/1.1
39+
Host: api.acme.com
3640
```
3741

3842
**Response:**
3943
```json
4044
{
41-
"name": "Acme CRM Production",
42-
"version": "2.1.0",
43-
"environment": "production",
45+
"version": "v1",
46+
"apiName": "ObjectStack API",
4447
"routes": {
4548
"data": "/api/v1/data",
46-
"metadata": "/api/v1/meta",
47-
"packages": "/api/v1/packages",
48-
"auth": "/api/v1/auth",
49-
"ui": "/api/v1/ui",
50-
"storage": "/api/v1/storage"
49+
"metadata": "/api/v1/meta"
5150
},
5251
"services": {
53-
"data": { "enabled": true, "status": "available", "route": "/api/v1/data", "provider": "objectql" },
54-
"metadata": { "enabled": true, "status": "available", "route": "/api/v1/meta", "provider": "objectql" },
55-
"auth": { "enabled": true, "status": "available", "route": "/api/v1/auth", "provider": "@objectstack/plugin-auth" },
52+
"metadata": { "enabled": true, "status": "available", "handlerReady": true, "route": "/api/v1/meta", "provider": "objectql" },
53+
"data": { "enabled": true, "status": "available", "handlerReady": true, "route": "/api/v1/data", "provider": "objectql" },
5654
"search": { "enabled": false, "status": "unavailable", "message": "No implementation ships for the 'search' slot — register a service under it to enable" },
5755
"ai": { "enabled": false, "status": "unavailable", "message": "Provided by @objectstack/service-ai in ObjectStack Cloud/Enterprise — no implementation ships in the open framework" }
5856
},
59-
"locale": {
60-
"default": "en-US",
61-
"supported": ["en-US", "zh-CN", "es-ES", "fr-FR"],
62-
"timezone": "America/Los_Angeles"
57+
"capabilities": {
58+
"cron": { "enabled": false },
59+
"automation": { "enabled": false },
60+
"search": { "enabled": false },
61+
"transactionalBatch": { "enabled": true, "description": "Atomic cross-object batch endpoint (POST {basePath}/batch)…" }
62+
},
63+
"scoping": {
64+
"enabled": false,
65+
"resolution": "auto",
66+
"scoped": false
67+
}
68+
}
69+
```
70+
71+
Three things about this body are worth stating explicitly, because they are what the
72+
`/.well-known/objectstack` document below does *not* share:
73+
74+
- **`version` is the configured API version, not a product version.** The handler
75+
overwrites the protocol's value with `api.version` — the same string that forms the
76+
path segment (`"v1"`). It is never a semantic version like `2.1.0`.
77+
- **There is no `name`, `environment` or `locale` here.** Those are dispatcher fields
78+
(see below). A client that initialises i18n from `locale` must read
79+
`/.well-known/objectstack`, not this response.
80+
- **`scoping` is added by the REST server**, so clients can detect dual-mode routing;
81+
`environmentId` is present only on the environment-scoped mount
82+
(`/api/v1/environments/:environmentId/...`).
83+
84+
Disabled/uninstalled route keys are omitted from `routes` entirely rather than set to
85+
`null`; check `services` to tell "not installed" apart from "installed but not yet mounted
86+
here." `capabilities` is a flat map of platform feature flags (`comments`, `automation`,
87+
`cron`, `search`, `export`, `chunkedUpload`, `transactionalBatch`), each derived from what
88+
is actually registered — never hardcoded. See
89+
[API → Discovery](/docs/api#discovery) for the field-by-field reference.
90+
91+
### `GET /.well-known/objectstack`
92+
93+
Served by the runtime dispatcher (`@objectstack/runtime`), not `@objectstack/rest` — its
94+
body is wrapped as `{ "data": { ... } }` and includes fields (`name`, `environment`,
95+
`features`, `locale`) that the `@objectstack/rest`-served `/api/v1` response above does
96+
not. This path is unconditionally dispatcher-owned: no other plugin registers it, so it
97+
answers with this shape whether or not REST is mounted. The client SDK's `connect()` tries
98+
`/api/v1/discovery` first and falls back to this endpoint, unwrapping either `body.data` or
99+
the bare `body`.
100+
101+
**Request:**
102+
```http
103+
GET /.well-known/objectstack HTTP/1.1
104+
Host: api.acme.com
105+
```
106+
107+
**Response:**
108+
```json
109+
{
110+
"data": {
111+
"name": "ObjectOS",
112+
"version": "1.0.0",
113+
"environment": "production",
114+
"routes": {
115+
"data": "/api/v1/data",
116+
"metadata": "/api/v1/meta",
117+
"packages": "/api/v1/packages",
118+
"auth": "/api/v1/auth",
119+
"ui": "/api/v1/ui",
120+
"i18n": "/api/v1/i18n"
121+
},
122+
"features": {
123+
"search": false,
124+
"websockets": false,
125+
"files": false,
126+
"analytics": false,
127+
"ai": false,
128+
"notifications": false,
129+
"i18n": true
130+
},
131+
"services": {
132+
"metadata": { "enabled": true, "status": "available", "handlerReady": true, "route": "/api/v1/meta", "provider": "kernel" },
133+
"data": { "enabled": true, "status": "available", "handlerReady": true, "route": "/api/v1/data", "provider": "kernel" },
134+
"auth": { "enabled": true, "status": "available", "handlerReady": true, "route": "/api/v1/auth" },
135+
"search": { "enabled": false, "status": "unavailable", "handlerReady": false, "message": "No implementation ships for the 'search' slot — register a service under it to enable" }
136+
},
137+
"locale": {
138+
"default": "en-US",
139+
"supported": ["en-US", "zh-CN"],
140+
"timezone": "UTC"
141+
}
63142
}
64143
}
65144
```
66145

146+
`name` and `version` are the dispatcher's own build identity, not your app's name — they
147+
are fixed strings, so do not display them as the deployment's title. `environment` is the
148+
process `NODE_ENV`. `locale` is derived from the registered i18n service (`getDefaultLocale()`
149+
/ `getLocales()`); with no i18n service it degrades to `{ "default": "en", "supported":
150+
["en"], "timezone": "UTC" }`. The body also repeats `routes` under an `endpoints` key as a
151+
backward-compatibility alias, and carries **no** `capabilities` map — that one exists only
152+
on the REST-served response above.
153+
154+
<Callout type="warn">
155+
**"Both paths return the same document" holds only in a REST-less composition.** There, the
156+
dispatcher owns `/api/v1/discovery` as the fallback registrant, so that path and
157+
`/.well-known/objectstack` both answer with the dispatcher payload above (the bare
158+
`/api/v1` is registered by `@objectstack/rest` alone and is not served at all). As soon as
159+
`@objectstack/rest` is mounted it takes `/api/v1/discovery` under the single-owner rule
160+
(ADR-0076 D11) and the two paths answer different shapes. Never write a client that reads
161+
`locale` or `environment` off `/api/v1/discovery`.
162+
</Callout>
163+
67164
**Why discovery matters:**
68165
- **Environment agnostic:** Works across dev, staging, production without hardcoding URLs
69166
- **Version tolerance:** API routes can change without breaking clients

0 commit comments

Comments
 (0)