Skip to content

Commit eca3fef

Browse files
committed
Merge remote-tracking branch 'origin/main' into claude/issue-5532-getmeta-outage-vs-miss
2 parents 61ce971 + 6ddb2ec commit eca3fef

42 files changed

Lines changed: 3975 additions & 171 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.
Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
---
2+
"@objectstack/service-analytics": patch
3+
"@objectstack/rest": patch
4+
---
5+
6+
fix(service-analytics,rest): an analytics dimension over a missing field answers 400 INVALID_FIELD, not a driver 500 (#5520)
7+
8+
#4437 gave a **measure** over a non-existent field a `400 INVALID_FIELD` naming
9+
the field, because a driver error class must never be the caller's `error.code`
10+
for a caller-shaped mistake (ADR-0112). It covered the measure half only, so the
11+
identical typo one request key over still reached the driver as a `GROUP BY`
12+
column:
13+
14+
```
15+
POST /analytics/query {"cube":"account_metrics","measures":["account_count"],"dimensions":["bogus_dim"]}
16+
→ 500 {"code":"SQLITE_ERROR","message":"Internal server error"}
17+
18+
# the control group on the same route, already fixed by #4437
19+
POST /analytics/query {"cube":"account_metrics","measures":["bogus_measure"]}
20+
→ 400 {"code":"INVALID_FIELD","message":"Measure 'bogus_measure' … Valid measures: …"}
21+
```
22+
23+
**The gate.** `ensureCube` now runs `assertDimensionFields` alongside
24+
`assertMeasureFields` on every path, so a dimension whose source column the
25+
backing object does not have is refused **before** any SQL is built, with the
26+
same envelope the measure gate uses: `INVALID_FIELD` / 400 plus
27+
`field` / `object` / `param`, a message naming the field, the valid dimensions,
28+
and the object's known field list. `query`, `generateSql` and `queryDataset` are
29+
all covered, and a rejected query leaves nothing behind in the cube registry.
30+
`timeDimensions` are covered too — they resolve through the same
31+
`cube.dimensions` bag and produced the same 500 — with `param` reporting which
32+
request key carried the bad name.
33+
34+
**What deliberately did not change:** grouping by a REAL field the cube never
35+
declared as a dimension (`dimensions: ["phone"]`) still works. The gate asks
36+
"does the *object* have this field", never "did the cube declare this
37+
dimension". A cube whose `sql` is an expression, a dotted relation dimension,
38+
and a host that wires no field-name probe are all stood down on, exactly as the
39+
measure gate stands down.
40+
41+
**The SQL echo, same request.** `POST /analytics/dataset/query` composed its own
42+
5xx body and echoed the error message verbatim. Knex prefixes the offending
43+
statement to its message, so the caller received the generated SQL — physical
44+
table and column names included:
45+
46+
```
47+
500 {"code":"ANALYTICS_QUERY_FAILED",
48+
"error":"SELECT bogus_dim AS \"bogus_dim\", COUNT(*) AS \"account_count\"
49+
FROM \"crm_account\" GROUP BY bogus_dim - no such column: bogus_dim"}
50+
```
51+
52+
The sibling face never leaked it: `/analytics/query` exits through the
53+
dispatcher, which has applied the shared `looksLikeInternalErrorLeak` predicate
54+
to every >= 500 message since #3867. That same predicate now guards this route's
55+
500 body. Classification is untouched — the status stays 500, the code stays
56+
`ANALYTICS_QUERY_FAILED`, the ADR-0112 envelope branch and the transitional
57+
message list are unchanged — and the full text still reaches server logs. A 500
58+
whose message does not look like driver output keeps its prose.
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
---
2+
"@objectstack/service-analytics": patch
3+
---
4+
5+
fix(service-analytics): dataset 响应的 `fields` 在「度量全部自带 filter」的路径上也描述维度列 (#5537)
6+
7+
一个 dataset 查询,只要它的**基础度量全部带有自身的 `filter`**(或它选中的 derived
8+
度量的依赖全部如此),响应里的 `fields` 就只剩度量列,被选中的维度**完全没有描述符**
9+
维度值一直都在 `rows` 里(它就是合并键),但读取列元数据的消费者拿不到维度列的
10+
`label``type`,只能退回去 humanize 原始行键。
11+
12+
HotCRM「Sales Performance」上肉眼可见:同一个声明了 `label: 'Owner'``owner` 维度,
13+
"Open Pipeline by Owner"(度量无 filter)表头是 `Owner`,而 "Win / Loss by Rep"
14+
(`won_count`/`lost_count` 各带 filter、`win_rate` 是 ratio)表头是小写 `owner`
15+
换成字符串维度 `lead_source` 看起来正常纯属巧合 —— humanize 后恰好等于真 label;
16+
两种维度的描述符其实都丢了。
17+
18+
根因在网格装配处,不在渲染端:`DatasetExecutor.runMeasurePass` 只有在存在**无 filter**
19+
度量时才发那条主查询;当每个基础度量都自带 filter 时,它从 `{ rows: [], fields: [] }`
20+
起步,而随后每个补充子查询只追加一个**度量**描述符。现在这种情况下,维度描述符取自
21+
**第一个补充子查询自己的结果** —— 它 group by 的维度与整个网格完全一致 —— 因此两条路径
22+
`fields` 形状(维度在前、顺序、`type`)按构造收敛,而不是靠 executor 再抄一份
23+
「哪些维度被投影」的规则(该规则的单一事实源在各 strategy 的 `buildFieldMeta`,#4033)。
24+
25+
`compareTo``totals` 与 derived 度量都经由同一条 pass,所以一并修好。
26+
27+
已知的相邻缺口**不在**本次修复范围,单独立了 #5688:一个只带 `dateRange`
28+
`timeDimensions` 条目会被补上 dataset 的默认粒度,于是「窗口」变成第二层 GROUP BY,
29+
网格被按月拆分、并多出一个没人选过的时间列(该列在 `fields` 里也拿不到 `label`)。
30+
它在两条路径上表现一致(本次修复前后皆然),且修它会改变响应形状,故不搭车。
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
---
2+
"@objectstack/spec": minor
3+
"@objectstack/metadata-protocol": minor
4+
"@objectstack/runtime": minor
5+
---
6+
7+
feat(spec,runtime,metadata-protocol)!: one schema for both discovery producers — `capabilities` canonical, `features`/`endpoints` retired, `scoping` declared (#4828)
8+
9+
`/discovery` is a machine-readable surface, but nothing compared what the two
10+
producers emit against what `packages/spec` declares. The only schema the
11+
protocol layer referenced was `GetDiscoveryResponseSchema`
12+
`DiscoverySchema.partial().required({version}).extend({apiName})` — so
13+
`.partial()` hid every missing REQUIRED key while zod's default unknown-key
14+
strip hid every UNDECLARED emitted one. The two producers then drifted in
15+
opposite directions through the same blind spot.
16+
17+
`DiscoverySchema` is now authoritative for producers, and each producer package
18+
carries a `discovery-schema-conformance.test.ts` that parses its LIVE shape
19+
against it and checks its emitted key set against the protocol schema's shape.
20+
21+
**Breaking for anyone reading the dispatcher's `/.well-known/objectstack` body:**
22+
23+
- `features`**`capabilities`**, the name `DiscoverySchema` has always
24+
declared, in the declared `{ enabled }` shape. The same flags survive. This
25+
fixes a real defect: the SDK's `client.capabilities` getter reads
26+
`discoveryInfo.capabilities`, so against a dispatcher-served host it returned
27+
`undefined` for every flag while the answers sat one key away under `features`.
28+
- `endpoints`**removed**. It duplicated `routes` verbatim as a
29+
"backward compatibility" alias; a consumer census across `objectstack`,
30+
`objectui` and `cloud` found no reader. Use `routes`.
31+
- `environment` is now **mapped** into its declared enum instead of passing
32+
`NODE_ENV` through raw (`test``development`, `staging``sandbox`,
33+
unrecognized → `development`, never `production` on a guess). `NODE_ENV=test`
34+
and `staging` previously advertised values outside the declared enum.
35+
36+
**Additive elsewhere:**
37+
38+
- `DiscoverySchema` declares `scoping` (optional) — the environment-scoping
39+
posture the REST endpoint has always emitted and `packages/client` has always
40+
consumed, now part of the contract instead of an undeclared extra.
41+
- The REST `/discovery` body gains the required `name` / `environment` /
42+
`locale`, so it can satisfy `DiscoverySchema` at all. `locale` is derived from
43+
the registered i18n service, the same way the dispatcher derives it.
44+
- `name` is canonical on both producers. `apiName` remains as a deprecated alias
45+
carrying the identical value and is **scheduled for removal in protocol 18**.
46+
- New exports: `DiscoveryEnvironmentSchema`, `DiscoveryEnvironment`,
47+
`resolveDiscoveryEnvironment`.
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
---
2+
"@objectstack/spec": patch
3+
---
4+
5+
fix(spec): 导航项的四条「展开」别名不再把作者指向该变体同样拒绝的键 (#5555)
6+
7+
`expanded` 只声明在 `group` 导航变体上,但 `defaultOpen` / `open` / `collapsed` /
8+
`isOpen` 这四条别名写在 `NAV_ITEM_ALIASES` —— 一张被盖进全部九个变体的共享表。于是在
9+
另外八个变体上,报错把作者指向一个下一步同样会被拒的键:
10+
11+
1.`{ type: 'url', url: '/x', defaultOpen: true }`
12+
2. 得到 ``Did you mean `defaultOpen` → `expanded`?``
13+
3. 照做改成 `expanded: true`
14+
4. **再次被拒**,而且第二次没有任何建议
15+
16+
这正是 #4001 战役要消灭的那个失败模式(ledger finding 7 的「二次拒绝」),由该战役
17+
自己的修复产生。
18+
19+
四条别名已挪进**按变体拼装**的那一段:`group` 保留裸键名重定向(`expanded` 在它身上
20+
是真键),另外八个变体改用散文 target —— 与同一段里既有的六条跨变体别名同形:
21+
22+
```
23+
Did you mean `defaultOpen` → `type: 'group' (with expanded)`?
24+
```
25+
26+
**只改面向作者的报错文案,schema 形状没有变化**:接受与拒绝的键集合、类型、默认值
27+
全部不变,已有的元数据不受影响。`group` 上的四条别名行为也不变。

.claude/agents/os-dev.md

Lines changed: 14 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -138,13 +138,20 @@ Definition of done, in order:
138138
moment the PR exists. Nothing applies it for you: `.github/labeler.yml` has no
139139
rule for it, and in every 2026-08-05 case
140140
(#5533/#5538/#5542/#5624/#5642/#5645) the label came from an agent, never from
141-
`github-actions[bot]`. **Add** the one label instead of writing the label set
142-
— a set-write wipes the `size/*` / `documentation` / `tests` the bots just
143-
applied, and CI's own write can wipe yours back (#5533's lasted one second).
144-
Then read the labels back once the bots have settled and quote that list in
145-
the report; the read, not the POST, is what closes this step. Check Changeset
146-
re-reads the labels live in its first step (#5580), so the first run is a race
147-
between that step and your POST, decided by runner start-up — and it is
141+
`github-actions[bot]`. **Read the labels back first, then write the union**
142+
the existing set + `skip-changeset` — because `issue_write`'s `labels` field
143+
is a whole-set PUT: `labels: ['skip-changeset']` alone wipes the `size/*` /
144+
`documentation` / `tests` the bots just applied, and CI's own write can wipe
145+
yours back (#5533's lasted one second). Tool surface, not style: #5683
146+
measured it — the bare set emitted two `unlabeled` events in one second, the
147+
union write only `labeled`. The additive `POST /issues/{n}/labels` is out of
148+
reach (no `gh` CLI, unauthenticated `curl` cannot write), so read with REST
149+
`GET /repos/{owner}/{repo}/pulls/{n}``issue_read get_labels` cannot
150+
resolve a PR number. Then read the labels back once the bots have settled and
151+
quote that list in the report; the read, not the write, is what closes this
152+
step. Check Changeset re-reads the labels live in its first step (#5580), so
153+
the first run is a race between that step and your write, decided by runner
154+
start-up — and it is
148155
attested both ways: #5542 labelled correctly and still logged a red `opened`
149156
run, while #5650's label landed 41 s ahead of the re-read and that same
150157
`opened` run went green. So land the label fast, and read the first run's

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

Lines changed: 55 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -346,6 +346,12 @@ issues that collide on shared files — and「谁来分诊」原本是每个 PM
346346
—— Routine 座位查调度器(`last_fired` / `next_run`),会话座位查它最近一条
347347
产出评论的时间戳,**>24h 无产出即可回收**(编辑该行 + 一条审计评论)。子树/
348348
批次里在飞的认领仍按认领协议由原认领者跟完。
349+
- **每轮巡检核对自己的正文行。** 协议或表格结构升级会迁移状态:2026-08-05
350+
spec 座位的在任 PM(接管时按升级前惯例只发过登记评论)在正文表格化迁移后
351+
被记为「⏳ 待认领 / 前任已收官」—— 而上一条的惰性回收会把这个错位变成
352+
误回收,别的会话也可能照着「待认领」真来接管。自查一行的成本是零;发现
353+
不符,当场改行 + 审计评论,不等冲突发生。这与「从 labels 重建状态」同源:
354+
正文行也是状态,读它、修它,不靠记忆。
349355
- **epic 委托不在座位表登记** —— `pm:epic` 父单正文自带会话与领地,
350356
`label:pm:epic` 即全量索引(见「Epic 子树车道」)。座位表只记常设座位,一件
351357
事只记一处。
@@ -987,8 +993,11 @@ disobedience.
987993

988994
`/compact`, and any host-level interruption of the PM session, kills running
989995
subagents together with their pending tool calls — #4700 and #4775 both died on
990-
the same second. **The agent is not resumable; its worktree, branch and commits
991-
are intact.** Never re-run the original dispatch prompt over that state: a fresh
996+
the same second. **先试 SendMessage 复活,再谈接手**:对已死 agent 发一条消息,
997+
宿主会「从 transcript 恢复」—— 带着它全部上下文接着干,worktree、分支、提交
998+
都还在(2026-08-05 三个死 agent 全部由此复活并各自收尾,见 step 6 探活)。
999+
仅当 resume 不可用(跨会话接手、transcript 丢失)时才走下面的 worktree 接手
1000+
协议。Never re-run the original dispatch prompt over that state: a fresh
9921001
agent that follows it will try to create the worktree that already exists, or
9931002
redo work already committed. Dispatch a **new** agent with these four additions
9941003
instead:
@@ -1093,9 +1102,26 @@ connector grant 只能传递调用会话自身持有的,CCR 平台注入的 gith
10931102

10941103
### 6. Collect
10951104

1096-
**Subagent mode:** wait for the background task notifications — do not poll,
1097-
do not fabricate a pending agent's result. A dev that dies or returns
1098-
malformed output counts as `status: "blocked"` with its raw output attached.
1105+
**Subagent mode:** wait for the background task notifications — do not poll
1106+
for *results*, do not fabricate a pending agent's result. A dev that dies or
1107+
returns malformed output counts as `status: "blocked"` with its raw output
1108+
attached.
1109+
1110+
**探活是每轮巡检的固定动作 —— 完成通知不可靠,它的缺席什么都不证明。**
1111+
下面的停摆纠偏处理「带任务中状态的通知到了」;这一条处理更隐蔽的另一半:
1112+
**通知根本不来**。宿主进程重启会把运行中的 subagent 连同其完成通知一起
1113+
静默杀掉 —— 2026-08-05 实测,五个「在飞」dev 里三个(#5050/#5515/#5483)
1114+
已死数小时,批次视图仍显示 5/5,实际吞吐 2/5,零信号。规程三条:
1115+
1116+
- 每次巡检(定时器唤醒、轮间隙)对**每个已派发且尚无远程分支/PR**
1117+
dev 发一次状态询问(SendMessage,措辞「回一段简报后继续干活」,不改变
1118+
任务);派发后 ~45 分钟无任何远程产出即到探活门槛。
1119+
- 两种回包都有价值:活着 → 拿到进度与阻塞点;**「no active task; resumed
1120+
from transcript」→ agent 生前已死,这次询问本身就是复活** —— 从其
1121+
transcript 带全部上下文恢复,比 worktree 接手协议(step 5)便宜得多,
1122+
优先用它;resume 不可用时才走接手协议。
1123+
- 判据永远取正向证据(远程分支、PR、报告、探活回包),⛔ 绝不把「还没
1124+
收到失败通知」读作「还在跑」。
10991125

11001126
**A stalled subagent is this half's most common failure, and it never
11011127
self-heals.** When a dev stops mid-task reasoning that "a background watcher will
@@ -1142,6 +1168,14 @@ dispatch 由下一轮按同一判据处置(~2h 无报告即 `blocked`),`delete_t
11421168
Routine 的取舍是:凡验证管线可能超过一个 fire 的活,**一开始就走 `mode:cloud`**,
11431169
把恢复权交给下一轮的 GitHub 读数,而不是赌它能在本轮内被唤醒。
11441170

1171+
**报告丢失 ≠ 验收停摆(直接验收兜底)。** dev 的 JSON 报告是证据来源之一,
1172+
不是验收的先决条件 —— 状态模型第一句就是「所有状态在 GitHub」。同时满足
1173+
(a) draft PR 已存在且 CI 全绿、(b) 探活确认 agent 已死或 ≥2h 无任何推送、
1174+
(c) 报告未达 —— 则 PM 直接按 PR 验收:逐文件核对 diff 与认领申报的文件面,
1175+
对照 `origin/main` 复核 PR 正文的前提声明与验证叙述,step 7 其余判据不变
1176+
(2026-08-05 的 #5550/#5556 即此路径落地并合并)。顺序保护:agent 可能还
1177+
活着时**先探活、后翻 ready** —— 抢先翻会与它的收尾推送竞态。
1178+
11451179
### 7. Review each report
11461180

11471181
You are the reviewer of record. For each report, verify against GitHub — not
@@ -1187,6 +1221,22 @@ against the report's own claims:
11871221
correction belongs in the PR/issue comments so the next reader inherits the
11881222
corrected premise, and a wrong premise still sitting in an issue body gets
11891223
its own follow-up issue rather than being silently dropped.
1224+
- **验收判据本身也是前提的一部分,可被 dev 证伪。** #5452 的 issue 把验收写成
1225+
「某条字面 grep 归零」,dev 实测证明该 pattern 修前修后命中数不变(修好的
1226+
正确输出同样匹配它),于是改钉真不变量(行内代码跨度花括号配平)做门禁,
1227+
并因此多抓出 2 处 issue 的 grep 天然看不见的同根因缺陷。评审姿势:dev
1228+
测量推翻字面判据、换上等价或更强的不变量门禁 = 好运行,照 ACCEPT;但推翻
1229+
过程必须写在 PR 正文里,且新判据要附在 main 语料上的实测信噪比(误报为零
1230+
的证据),否则按 REWORK 要证据。
1231+
- **Tests/docs-only PR 走 `skip-changeset` 标签,不走空 changeset**(空
1232+
changeset 滞留发布,#4898)。标签由 PM 在验收时打。历史坑(#5497/#5502
1233+
实测):该闸曾从**事件载荷**读标签,rerun 重放旧载荷看不见新标签,得靠
1234+
「摘掉再打回」制造新 labeled 事件 —— **#5625(#5580)已根治**,闸门改为
1235+
实时读 PR 标签,rerun 即翻绿。留此一条是因为它是一类通病的标本:**任何
1236+
从事件载荷而非现状读判据的闸,rerun 都复现旧世界** —— 撞上同形状的红,
1237+
先查该闸读的是载荷还是现状,再决定是补事件还是改闸。
1238+
边界:改动若含读者可见的生成产物(如参考文档),dev 选 changeset 而非
1239+
标签是对的 —— 以 PR 正文说明的理由为准,两条路都有效,别来回改。
11901240
- **`+0/-0` in a PR diff is not proof of an empty file.** git renders a file
11911241
as binary — zero added, zero removed — as soon as it contains a NUL byte.
11921242
#4870's 347-line test file showed `+0/-0` and was briefly misread as an

0 commit comments

Comments
 (0)