Skip to content
Merged
14 changes: 10 additions & 4 deletions src/backend/docs/bot-config-manifest/design.zh-CN.md
Original file line number Diff line number Diff line change
Expand Up @@ -346,7 +346,11 @@ apply 在平台侧执行,天然产出结构化记录(#935 的 `last-start`
"apply_id": "…", "bot_id": "…", "trigger": "create|republish|restart|explicit",
"started_at": "…", "finished_at": "…", "result": "SUCCEEDED|PARTIAL|FAILED",
"sources": [
{"name": "content", "ref": "v1.2.0", "resolved_sha": "9c1f4ae…"}
{"name": "content", "url": "https://code.example.com/team/content.git",
"ref": "v1.2.0", "mode": "strict", "resolved_sha": "9c1f4ae…"},
{"name": "https://code.example.com/team/tools.git@main",
"url": "https://code.example.com/team/tools.git",
"ref": "main", "mode": "non_strict", "resolved_sha": "7e3b91c…"}
],
"entries": [
{"category": "skills", "name": "reviewer",
Expand All @@ -356,9 +360,11 @@ apply 在平台侧执行,天然产出结构化记录(#935 的 `last-start`
}
```

命名源的解析结果记在顶层 `sources`(声明的 `ref` + 解析出的
`resolved_sha`)——「这批 bot 线上跑的是哪一版内容」由此可查;条目层记
`from`(来自哪个源)或 `source_digest`(URL 源)。
git 源的解析结果记在顶层 `sources`(`url` + 声明的 `ref` + 解析出的
`resolved_sha`)——「这批 bot 线上跑的是哪一版内容」由此可查;**每条声明一行**,
内联源没有名字,记成 `url@ref`。`strict` 的基线按 `(url, ref, mode)` 从这里读回去
(schema §2.3),跟这一行叫什么名字无关。条目层记 `from`(来自哪个源)或
`source_digest`(URL 源)。

经 `GET …/config-manifest/last-apply` 暴露。script 的输出维持现状:容器内
`/home/admin/logs/startup_script.log`。
Expand Down
22 changes: 18 additions & 4 deletions src/backend/docs/bot-config-manifest/manifest-schema.zh-CN.md
Original file line number Diff line number Diff line change
Expand Up @@ -472,13 +472,27 @@ manifest:
| `mode` | 行为 |
| --- | --- |
| `non_strict`(**默认**) | 应用新内容,并在 apply report 里对该条目**告警**,写明前后两个 SHA |
| `strict` | 解析出的 SHA 与上次 apply 记录的不同时,该条目**失败**,bot 继续跑它现在跑的 |

| `strict` | 同一个 `(url, ref, mode)` 这次解析出的 SHA 与上次 apply 记录的不同时,该条目**失败**,bot 继续跑它现在跑的 |

- **基线按 `(url, ref, mode)` 记**,不按源名。两个分支问的都是同一件事:「**这个
仓库的这个 ref**,在我们上次解析它之后动过没有」。源叫什么是文档的事,跟这个
问题无关——改名不会丢基线,把 `url` 指向另一个仓库也不会继承前一个仓库的 SHA。
- **`mode` 也在键里**,理由只有一个:**一个钉扎只能被同样模式下的那次 apply 推进**。
同一个 `(url, ref)` 在一份文档里被声明两次(一个 `strict`、一个 `non_strict`)是
合法的。ref 移动时,宽松的那条会正常下发并记下新 SHA,钉死的那条拒绝。若两者共用
基线,那个新 SHA 就会变成钉扎的基线,下一次(文档一个字没改的)apply 就会把它刚
拒绝掉的那个 commit 交给它——`strict` 退化成「每次移动只拒绝一次,然后照单全收」。
带上 `mode`,两条声明各记各的:宽松的往前走,钉死的继续拒绝,直到文档重新钉扎。
- **改 `ref` 或改 `url` 就是一次重新钉扎**:文档写出来的新 `(url, ref)` 没有任何
一次 apply 对它有意见,于是既不拒绝、也不告警,正常解析并被这次 apply 记下。
这也是 `strict` 源升版的唯一正道——不必先切成 `non_strict` 应用一次再切回来。
`strict` 拒绝的始终只有一种情况:**文档没动,而 ref 在脚下动了**。
- **SHA 形式的 `ref` 两个分支都触发不了**——它解析出来永远是它自己,
`(url, <sha>)` 的基线只可能等于同一个 SHA。这是构造上的结果,不是一条特例。
写 `mode` 是「接受但无效」,不是报错。
- **写在源上**,不是按 bot、也不是按清单——要描述的性质是「这个 ref 允不允许
在我脚下移动」,它属于持有 `ref` 的那个东西。一份清单里同时有一个钉死的
外部依赖和一个快速变动的内部仓库是常态。
- **SHA 形式的 `ref` 忽略这个模式**(它动不了,两个分支都触发不了)——是
「接受但无效」,不是报错。
- 未知取值 `PUT` 时拒绝:拼错的 `mode` 若静默落到默认值,等于什么都没钉住。
- 内联 git `source` 同样接受 `mode`(它也持有 `ref`);`oss` 源写 `mode` 会被
`PUT` 拒绝——它没有 ref 可以移动。
Expand Down
39 changes: 32 additions & 7 deletions src/backend/docs/bot-config-manifest/user-manual.zh-CN.md
Original file line number Diff line number Diff line change
Expand Up @@ -678,7 +678,11 @@ GET /openapi/v1/bots/{bot_id}/with-manifest/status
"started_at": "…", "finished_at": "…",
"result": "SUCCEEDED|PARTIAL|FAILED",
"sources": [
{"name": "content", "ref": "v1.2.0", "resolved_sha": "9c1f4ae…"}
{"name": "content", "url": "https://code.example.com/team/content.git",
"ref": "v1.2.0", "mode": "strict", "resolved_sha": "9c1f4ae…"},
{"name": "https://code.example.com/team/tools.git@main",
"url": "https://code.example.com/team/tools.git",
"ref": "main", "mode": "non_strict", "resolved_sha": "7e3b91c…"}
],
"entries": [
{"category": "identity", "name": "SOUL.md", "action": "updated", "from": "content"},
Expand Down Expand Up @@ -1020,12 +1024,25 @@ schema 已定稿(见 `manifest-schema.zh-CN.md` §3.4),但**第一期没
| `mode` | 行为 |
| --- | --- |
| `non_strict`(**默认**) | 应用新内容,并在 apply 报告里对该条目**告警**,写明前后两个 SHA |
| `strict` | 解析出的 SHA 与上次 apply 记录的不同时,该条目**失败**,bot 继续跑它现在跑的 |

| `strict` | 同一个 `(url, ref, mode)` 这次解析出的 SHA 与上次 apply 记录的不同时,该条目**失败**,bot 继续跑它现在跑的 |

- **基线按 `(url, ref, mode)` 记**,不按源名。两个分支问的都是同一件事:「这个仓库
的这个 ref,在我们上次解析它之后动过没有」——源叫什么是你文档里的事,跟这个问题
无关。改名不丢基线;把 `url` 指到另一个仓库也不会继承前一个仓库的 SHA。
- **`mode` 在键里**:同一个 `(url, ref)` 你可以声明两次,一条 `strict`、一条
`non_strict`(「这几个条目可以跟着分支走,那个不行」)。ref 动了以后,宽松的那条
正常下发并记下新 SHA,钉死的那条拒绝——两者**各记各的基线**,所以宽松的那条不会
把钉死的那条的基线推上去。否则下一次 apply(你一个字都没改)就会把刚被拒绝的那个
commit 交给钉死的条目。
- **改 `ref`(或改 `url`)就是一次重新钉扎**:新的 `(url, ref)` 没有任何一次
apply 对它有意见,所以既不拒绝也不告警,照常解析并被这次 apply 记下。
**这就是 `strict` 源的升版方式**——不用先切 `non_strict` 应用一次再切回来。
`strict` 拒绝的只有一种情况:**你没改文档,而 ref 在脚下动了**。
- **SHA 形式的 ref 两个分支都触发不了**——它只会解析成它自己。是「接受但无效」,
不是报错。
- **写在源上**,不是按 bot、也不是按清单——要描述的性质是「这个 ref 允不允许在我
脚下移动」,它属于持有 ref 的那个东西。一份清单里同时有一个钉死的外部依赖和一个
快速变动的内部仓库是常态。
- **SHA 形式的 ref 忽略这个模式**(它动不了)——是「接受但无效」,不是报错。
- 拼错的取值会被拒绝,不会静默落到默认值。

### 6.3 `digest`:哪里强制、哪里非法
Expand Down Expand Up @@ -1233,7 +1250,8 @@ bot 也是队列上的一个任务。所以部署里必须满足两个前提:

- 用的是 **tag 且没动**?那就是没变——改 `ref`(§4.8)。
- 用的是 **branch 且 `mode: strict`**?SHA 变了会让该条目**失败**,这是你要的钉扎
语义。看报告里的前后 SHA。
语义。看报告里的前后 SHA。要让它跟上,就在文档里把 `ref` 改成你真正想要的那个
tag 或 commit——换了 `ref` 就是一次重新钉扎,不会被拒绝(§6.2)。
- 取源失败并落到了 **`keep_last`**?报告里那一条会写明。

---
Expand Down Expand Up @@ -1738,7 +1756,7 @@ B.2.2 / B.2.3 / B.2.4 与 `GET …/with-manifest/status` 的 `apply` 字段都
| `result` | enum \| `""` | `RUNNING` / `SUCCEEDED` / `PARTIAL` / `FAILED`,见 B.7。终态是从逐条结果**推导出来的摘要,给人看的**。**空报告时是空串** |
| `started_at` | datetime \| null | 开始时间;bot 从没 apply 过时 `null` |
| `finished_at` | datetime \| null | 结束时间。**`null` 有两个原因,别拿它判「在跑」**:`result` 是 `RUNNING`(真的在跑),或者这是一份**空报告**(`result` 为空串)。要判在飞的活,读 `result == "RUNNING"`,不要读 `finished_at == null` |
| `sources` | object[] | 命名源的溯源,每个源一行,见下。**「这批 bot 线上跑的到底是哪一版内容」看这里** |
| `sources` | object[] | git 源的溯源,**每条声明一行**,见下。**「这批 bot 线上跑的到底是哪一版内容」看这里** |
| `categories` | object[] | 每个**被声明的**类目一行,见下。文档没提的类目不出现,因为它根本没被碰 |
| `entries` | object[] | 每个**被声明的条目**一行,跨所有类目,见下 |
| `notes` | string[] | 不属于任何条目的 apply 级说明。今天只有一处:teclaw 上「所有类目都写完了、最后整包 artifact 重投失败」记在这里,而不是让整次 apply 失败。ARCA 上恒为空 |
Expand All @@ -1747,11 +1765,18 @@ B.2.2 / B.2.3 / B.2.4 与 `GET …/with-manifest/status` 的 `apply` 字段都

| 字段 | 类型 | 含义 |
| --- | --- | --- |
| `name` | string | 源名(`sources.<name>` 里的那个名字) |
| `name` | string | 源名(`sources.<name>` 里的那个名字);**内联 `source` 没有名字,记成 `url@ref`**(省略 `ref` 时是 `url@HEAD`) |
| `url` | string \| null | 仓库地址,`${BOT_*}` 已替换。与 `ref`、`mode` 合起来就是 `strict` 基线的键(§6.2) |
| `ref` | string \| null | 声明的 ref:tag / branch / commit SHA |
| `mode` | string \| null | 这一次解析所用的 `strict` / `non_strict`。它是基线键的一部分,不是备注(§6.2) |
| `resolved_sha` | string \| null | 这一次**实际解析到**的 commit。`ref: main` 这种会动的引用,下周就是另一个值 |
| `auth` | string \| null | 用到的凭证**名**。**永远只有名字,没有值** |

**一条声明一行**:两个 `from` 名指向同一个 `(url, ref)` 就是两行(各自带着作者
写下的那个名字),`resolved_sha` 相同;同一个仓库内联声明两个 `ref` 也是两行。
同一个 `(url, ref)` 内联声明两次、只有 `mode` 不同,同样是两行——内联源的 `name`
都是 `url@ref`,这两行靠 `mode` 区分。

`categories[]`:

| 字段 | 类型 | 含义 |
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@
ConfigManifestApply,
ConfigManifestApplyCategory,
ConfigManifestApplyEntry,
ConfigManifestApplySource,
)
from .schemas import (
ConfigManifestApplyStarted,
Expand Down Expand Up @@ -257,7 +258,9 @@ def apply_payload(report: ApplyReport) -> ConfigManifestApply:
result=payload["result"],
started_at=report.started_at,
finished_at=report.finished_at,
sources=payload["sources"],
sources=[
ConfigManifestApplySource(**source) for source in payload["sources"]
],
categories=[
ConfigManifestApplyCategory(**category)
for category in payload["categories"]
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,49 @@ class ConfigManifestApplyEntry(BaseModel):
)


class ConfigManifestApplySource(BaseModel):
"""One git source this apply resolved — one row per declaration.

Named per declaration, keyed on the repository: two `from` names pointing at
the same `(url, ref, mode)` are two rows carrying the same `resolved_sha`,
and two inline declarations of one repository at two refs are two rows too.
"""

name: str = Field(
description="The `from` name, or `<url>@<ref>` for a source written "
"inline on an entry."
)
url: str | None = Field(
default=None,
description="The repository URL, with any `${BOT_*}` placeholder "
"already substituted. Together with `ref` and `mode` it is what strict "
"mode compares the next apply against — so re-pointing url or ref is a "
"re-pin, not a moved ref.",
)
ref: str | None = Field(
default=None,
description="The ref as declared: a tag, a branch, or a commit SHA. "
"`HEAD` when the source declared none.",
)
mode: str | None = Field(
default=None,
description="`strict` or `non_strict`, as the source declared it. Part "
"of the baseline key, so a `non_strict` declaration of a repository "
"never advances the baseline a `strict` declaration of the same "
"repository is pinned against.",
)
resolved_sha: str | None = Field(
default=None,
description="The commit that ref actually resolved to in this apply — "
"what tells you which version of the content this bot is running.",
)
auth: str | None = Field(
default=None,
description="The credential's name, never its value. Null for an "
"anonymous fetch.",
)


class ConfigManifestApplyCategory(BaseModel):
"""One category's summary, including what overwriting it removed."""

Expand Down Expand Up @@ -131,14 +174,13 @@ class ConfigManifestApply(BaseModel):
finished_at: datetime | None = Field(
default=None, description="Null exactly while `result` is `RUNNING`."
)
sources: list[dict] = Field(
sources: list[ConfigManifestApplySource] = Field(
default_factory=list,
description="Provenance for the manifest's named remote sources: what "
"each `source` name actually resolved to, including the exact "
"`resolved_sha`, since a moving `ref` like `main` means something "
"different next week. Always empty in this release — nothing is fetched "
"yet — and filled once remote sources are supported. A credential "
"appears by name only, never by value.",
description="Provenance for the manifest's git sources: what each "
"declaration actually resolved to, including the exact `resolved_sha`, "
"since a moving `ref` like `main` means something different next week. "
"One row per declaration; empty when the document names no git source. "
"A credential appears by name only, never by value.",
)
categories: list[ConfigManifestApplyCategory] = Field(
default_factory=list,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -342,25 +342,25 @@ class SourceResolution:
Only the git road produces these: an object-store source resolves no ref,
so it contributes no row. A named source::

SourceResolution(
name="content", # the 'from' name
ref="v1.2.0", # the declared ref, verbatim
resolved_sha="4f2a9c1b8e7d6a5c4b3a2918f7e6d5c4b3a29187",
auth="git-prod", # the credential NAME
)

An inline git source has no ``from`` name, so it is recorded under its
repository URL instead::

SourceResolution(
name="https://code.example.com/team/content.git",
ref="HEAD",
resolved_sha="4f2a9c1b8e7d6a5c4b3a2918f7e6d5c4b3a29187",
auth=None,
)

``name`` is the same key the strict-mode baselines are read back by, so the
report and ``SourceSession.baselines`` agree on a source's identity.
SourceResolution(name="content",
url="https://code.example.com/team/content.git",
ref="v1.2.0", mode="strict",
resolved_sha="7c1d…", auth="gh-readonly")

An inline git source has no ``from`` name, so it is named by the repository
and the ref it declared, joined by ``@``::

SourceResolution(name="https://code.example.com/team/content.git@main",
url="https://code.example.com/team/content.git",
ref="main", mode="non_strict",
resolved_sha="9e8d…", auth=None)

``name`` is the **display**: one row per declaration, so two names pointing
at one repository are two rows and two inline declarations of one
repository at two refs are two rows. ``(url, ref, mode)`` is what the
strict-mode baselines are read back by — the display plays no part in that,
because "has this repository's ref moved since we last resolved it" is not
a question about what the document called the source.

Created by: ``apply/source_session.SourceSession.adopt``, one per distinct
``display`` name, returned through ``resolution_records()``.
Expand All @@ -371,20 +371,36 @@ class SourceResolution:
support engineer reads, so this is a security property rather than tidiness.
"""

#: The ``from`` name, or the repository URL for an inline source.
#: The ``from`` name, or ``<url>@<ref>`` for an inline source.
name: str
#: The substituted repository URL — no credentials, which this record is
#: structurally unable to carry anyway: it holds names, never values. Part
#: of the key the next apply reads its baseline by, and ``None`` only on a
#: row written before this field existed.
url: str | None = None
#: The ref as declared: a tag, a branch, or a full SHA. ``"HEAD"`` when the
#: source declared none.
ref: str | None = None
#: The 40-character commit id the ref actually resolved to.
resolved_sha: str | None = None
#: The ``mode`` this resolution was made under, ``"strict"`` or
#: ``"non_strict"``. The last third of the baseline key, and load-bearing
#: rather than informational: a pin may only be advanced by an apply that
#: stood behind it under the *same* mode. Were it left out, a document
#: naming one ``(url, ref)`` twice — once ``strict``, once ``non_strict`` —
#: would let the lax declaration record a moved sha that the pinned one had
#: just refused, and the next apply would hand the pinned entry the very
#: commit it rejected: "refuse each move once, then deliver it".
mode: str | None = None
#: The credential's name, never its value. ``None`` for an anonymous fetch.
auth: str | None = None

def as_dict(self) -> dict[str, Any]:
return {
"name": self.name,
"url": self.url,
"ref": self.ref,
"mode": self.mode,
"resolved_sha": self.resolved_sha,
"auth": self.auth,
}
Expand Down Expand Up @@ -415,7 +431,9 @@ class ApplyReport:
sources=(
SourceResolution(
name="content",
url="https://code.example.com/team/content.git",
ref="v1.2.0",
mode="strict",
resolved_sha="4f2a9c1b8e7d6a5c4b3a2918f7e6d5c4b3a29187",
auth="git-prod",
),
Expand Down Expand Up @@ -472,7 +490,9 @@ def as_payload(self) -> dict[str, Any]:
"result": "PARTIAL",
"started_at": "2026-03-01T09:00:00+00:00",
"finished_at": "2026-03-01T09:00:04+00:00",
"sources": [{"name": "content", "ref": "v1.2.0",
"sources": [{"name": "content",
"url": "https://code.example.com/team/content.git",
"ref": "v1.2.0", "mode": "strict",
"resolved_sha": "4f2a9c1b...", "auth": "git-prod"}],
"categories": [{"category": "mcp", "aborted": False,
"partially_written": False, "removed": []}],
Expand Down
Loading
Loading