diff --git a/.github/workflows/qa.yml b/.github/workflows/qa.yml index 74ee47e95..c908bdb70 100644 --- a/.github/workflows/qa.yml +++ b/.github/workflows/qa.yml @@ -40,6 +40,10 @@ on: - 'tests/test597-dashboard-slash-namespace/**' - 'tests/test679-task-trace/**' - 'tests/lib/**' + - 'docs-site/**' + - 'docs/doc-source-pins-baseline.txt' + - 'scripts/check-doc-source-pins.py' + - 'tests/test831-doc-source-pins/**' push: branches: [main] paths: @@ -70,6 +74,10 @@ on: - 'tests/test597-dashboard-slash-namespace/**' - 'tests/test679-task-trace/**' - 'tests/lib/**' + - 'docs-site/**' + - 'docs/doc-source-pins-baseline.txt' + - 'scripts/check-doc-source-pins.py' + - 'tests/test831-doc-source-pins/**' # Older runs on the same ref get cancelled — saves minutes when a PR is # updated rapidly. main pushes run independently. @@ -244,3 +252,25 @@ jobs: echo "================ $f ================" tail -60 "$f" done + + doc-source-pins: + name: doc source-pin floor (Docker) + runs-on: ubuntu-latest + timeout-minutes: 8 + steps: + - uses: actions/checkout@v4 + + # 🔴 这道门守的是下限,不是正确性:它只保证「已知失效的行号 pin 不再变多」。 + # 实测召回率 5/10 —— 抓不到「锚点指着一行正常代码、只是不是它声称的那 + # 一行」。见 #831 与 scripts/check-doc-source-pins.py 的文件头。 + # 别拿这个 job 的绿色去论证 #831 已解决。 + - name: Build test831-doc-source-pins + run: | + docker build \ + --build-arg TEST831_SOURCE_COMMIT="$GITHUB_SHA" \ + --build-arg TEST831_RUNSH_BLOB="$(git rev-parse "HEAD:tests/test831-doc-source-pins/run.sh")" \ + -t anet-test831-doc-source-pins \ + -f tests/test831-doc-source-pins/Dockerfile . + + - name: Run test831-doc-source-pins + run: docker run --rm --network none anet-test831-doc-source-pins diff --git a/docs-site/docs/api/mcp-tools.md b/docs-site/docs/api/mcp-tools.md index 0004d4cbf..e6b8a3c2b 100644 --- a/docs-site/docs/api/mcp-tools.md +++ b/docs-site/docs/api/mcp-tools.md @@ -22,7 +22,7 @@ CommHub Server 共注册约 40 个 MCP Tools;本页文档化其中 agent 日 ### report_status -> [源码 ↗](https://github.com/sleep2agi/agent-network/blob/main/server/src/tools.ts#L88) +> [源码 ↗](https://github.com/sleep2agi/agent-network/blob/main/server/src/tools.ts) —— 搜 `"report_status"`(注册点在 `server/src/tools.ts`,全仓唯一) 上报 Agent 状态。同时用作心跳(建议每 3 分钟调用一次)。 @@ -43,7 +43,7 @@ CommHub Server 共注册约 40 个 MCP Tools;本页文档化其中 agent 日 | `project_dir` | string | | 工作目录 | | `version` | string | | Agent 版本 | | `tmux_name` | string | | tmux session 名 | -| `node_id` | string | | 节点稳定标识。**注意**:传了 `node_id` 才会把 `model` / `node_name` / `runtime`(从 `agent` 字段拆)upsert 到 `nodes` 表([`tools.ts:168-188`](https://github.com/sleep2agi/agent-network/blob/main/server/src/tools.ts#L168))。`model` 参数本身**不依赖 `node_id`** —— `report_status` 的 `sessions` upsert 无条件写 `sessions.model = COALESCE(model, 旧值)`([`tools.ts:129` INSERT + `tools.ts:141` ON CONFLICT](https://github.com/sleep2agi/agent-network/blob/main/server/src/tools.ts#L129));只有 `node_name` 没有 `sessions` 列、必须靠 `node_id` 走 `nodes` 表 | +| `node_id` | string | | 节点稳定标识。**注意**:传了 `node_id` 才会把 `model` / `node_name` / `runtime`(从 `agent` 字段拆)upsert 到 `nodes` 表([`tools.ts`](https://github.com/sleep2agi/agent-network/blob/main/server/src/tools.ts) 搜 `upsertNodeWithSec1Guard`(`report_status` 段内 `if (node_id)` 之下的调用点,以及 registerTools 之后的同名 helper))。`model` 参数本身**不依赖 `node_id`** —— `report_status` 的 `sessions` upsert 无条件写 `sessions.model = COALESCE(model, 旧值)`([`tools.ts`](https://github.com/sleep2agi/agent-network/blob/main/server/src/tools.ts) 在 `report_status` 段搜 `INSERT INTO sessions`(写这几列的是 `report_status`,不是本节这个 tool) 与 `model = COALESCE(?20, sessions.model)`);只有 `node_name` 没有 `sessions` 列、必须靠 `node_id` 走 `nodes` 表 | | `session_id` | string | | 运行时 session/thread ID | | `config_path` | string | | 配置文件路径 | | `channels` | string | | Channel 列表(JSON 数组字符串) | @@ -77,11 +77,11 @@ report_status({ ``` ::: warning 认证要求 -该 tool 只接受 **`ntok_`(network-scoped)token**。用 `utok_`(user-scoped)调用会返回 `{ok: false, error: "network_token_required"}`([`tools.ts:116-118`](https://github.com/sleep2agi/agent-network/blob/main/server/src/tools.ts#L116))。这是 v0.8 RFC-001 之后的硬约束 — agent 心跳必须绑定 network。 +该 tool 只接受 **`ntok_`(network-scoped)token**。用 `utok_`(user-scoped)调用会返回 `{ok: false, error: "network_token_required"}`([`tools.ts`](https://github.com/sleep2agi/agent-network/blob/main/server/src/tools.ts) 搜 `"network_token_required"`(全仓 3 处))。这是 v0.8 RFC-001 之后的硬约束 — agent 心跳必须绑定 network。 副作用:除了写 `sessions` 表,还会: -- 自动**删除同 network、同 alias、不同 resume_id** 的旧 session row([`tools.ts:127`](https://github.com/sleep2agi/agent-network/blob/main/server/src/tools.ts#L127);用于 agent 重启时清理孤儿) -- 当 `status="working"` 且有 `task` 时,触发 `tasks` 表 `delivered/acked → running` 状态切换([`tools.ts:150-153`](https://github.com/sleep2agi/agent-network/blob/main/server/src/tools.ts#L150);详见 [Task 生命周期](/concepts/task-lifecycle#状态机)) +- 自动**删除同 network、同 alias、不同 resume_id** 的旧 session row([`tools.ts`](https://github.com/sleep2agi/agent-network/blob/main/server/src/tools.ts) 搜 `DELETE FROM sessions WHERE alias = ?1 AND resume_id != ?2`;用于 agent 重启时清理孤儿) +- 当 `status="working"` 且有 `task` 时,触发 `tasks` 表 `delivered/acked → running` 状态切换([`tools.ts`](https://github.com/sleep2agi/agent-network/blob/main/server/src/tools.ts) 搜 `UPDATE tasks SET status = 'running'`;详见 [Task 生命周期](/concepts/task-lifecycle#状态机)) - 当 `node_id` 传入时 upsert `nodes` 表(含 `model` / `node_name` / `runtime`,详见 `node_id` 参数行) ::: @@ -89,7 +89,7 @@ report_status({ ### report_completion -> [源码 ↗](https://github.com/sleep2agi/agent-network/blob/main/server/src/tools.ts#L213) +> [源码 ↗](https://github.com/sleep2agi/agent-network/blob/main/server/src/tools.ts) —— 搜 `"report_completion"`(注册点在 `server/src/tools.ts`,全仓唯一) 汇报任务完成。会自动更新 session 状态为 idle。 @@ -128,11 +128,11 @@ report_completion({ ``` ::: tip 副作用(除了 completions 表 INSERT) -- **session 状态切换**:[`tools.ts:239-242`](https://github.com/sleep2agi/agent-network/blob/main/server/src/tools.ts#L239) `UPDATE sessions SET status='idle', task=NULL, progress=0` (按 alias) -- **任务状态切换**:[`tools.ts:244-266`](https://github.com/sleep2agi/agent-network/blob/main/server/src/tools.ts#L244) 把 `tasks` 行从 `delivered`/`acked`/`running` 切到 `replied`。先按 `task_id = ` 匹配;不命中再 fallback 用 `to_name= AND content=` 找最近一条 — 所以 `task` 参数实际可填**真实 task_id**(推荐)或**任务描述字符串**(fallback) -- **`result` 截断**:写 `tasks.result` 时只取前 4000 字符([`tools.ts:246`](https://github.com/sleep2agi/agent-network/blob/main/server/src/tools.ts#L246)),但完整 `result` 会进 `completions.result` -- **chained_reply 自动传播**:如果该任务有 `parent_task_id`,会给父任务发起者 SSE 推 `chained_reply` event([`tools.ts:271-291`](https://github.com/sleep2agi/agent-network/blob/main/server/src/tools.ts#L271);用于子任务回 → 链式通知父任务发起者,详见 [`task-lifecycle` 双写机制](/concepts/task-lifecycle#双写机制)) -- **`task_events` log**:记录一条 `replied` event([`tools.ts:270`](https://github.com/sleep2agi/agent-network/blob/main/server/src/tools.ts#L270)) +- **session 状态切换**:[`tools.ts`](https://github.com/sleep2agi/agent-network/blob/main/server/src/tools.ts) 搜 `UPDATE sessions SET status = 'idle'` `UPDATE sessions SET status='idle', task=NULL, progress=0` (按 alias) +- **任务状态切换**:[`tools.ts`](https://github.com/sleep2agi/agent-network/blob/main/server/src/tools.ts) 搜 `UPDATE tasks SET status = 'replied'`(全仓 2 处) 把 `tasks` 行从 `delivered`/`acked`/`running` 切到 `replied`。先按 `task_id = ` 匹配;不命中再 fallback 用 `to_name= AND content=` 找最近一条 — 所以 `task` 参数实际可填**真实 task_id**(推荐)或**任务描述字符串**(fallback) +- **`result` 截断**:写 `tasks.result` 时只取前 4000 字符([`tools.ts`](https://github.com/sleep2agi/agent-network/blob/main/server/src/tools.ts) 搜 `result.slice(0, 4000)`(全仓 2 处)),但完整 `result` 会进 `completions.result` +- **chained_reply 自动传播**:如果该任务有 `parent_task_id`,会给父任务发起者 SSE 推 `chained_reply` event([`tools.ts`](https://github.com/sleep2agi/agent-network/blob/main/server/src/tools.ts) 搜 `type: "chained_reply"`(全仓 2 处);用于子任务回 → 链式通知父任务发起者,详见 [`task-lifecycle` 双写机制](/concepts/task-lifecycle#双写机制)) +- **`task_events` log**:记录一条 `replied` event([`tools.ts`](https://github.com/sleep2agi/agent-network/blob/main/server/src/tools.ts) 搜 `logTaskEvent(updatedTaskId, null, "replied"`) 跟 [`send_reply`](#send-reply) 比较:`send_reply` 是 hub 工具,需要显式 `task_id` 参数;`report_completion` 是 agent 工具,可 fallback by content。 ::: @@ -141,7 +141,7 @@ report_completion({ ### get_inbox -> [源码 ↗](https://github.com/sleep2agi/agent-network/blob/main/server/src/tools.ts#L837) +> [源码 ↗](https://github.com/sleep2agi/agent-network/blob/main/server/src/tools.ts) —— 搜 `"get_inbox"`(注册点在 `server/src/tools.ts`,全仓唯一) 拉取待处理的消息。 @@ -181,7 +181,7 @@ report_completion({ ### ack_inbox -> [源码 ↗](https://github.com/sleep2agi/agent-network/blob/main/server/src/tools.ts#L871) +> [源码 ↗](https://github.com/sleep2agi/agent-network/blob/main/server/src/tools.ts) —— 搜 `"ack_inbox"`(注册点在 `server/src/tools.ts`,全仓唯一) 确认消息已接收。ACK 后消息不会再被 `get_inbox` 返回。 @@ -191,7 +191,7 @@ report_completion({ |------|------|:----:|------| | `alias` | string | ✓ | Session 别名 | | `message_id` | string | ✓ | inbox 投递行 `id`,或任务消息的逻辑 `task_id`。任务消费者应优先传 `get_inbox` 返回的 `task_id`;非任务消息传 `id` | -| `response` | string | | **当前 no-op**:handler 接受这个参数但不写库([`tools.ts:872-924`](https://github.com/sleep2agi/agent-network/blob/main/server/src/tools.ts#L872) 没有读取 `response`)。schema 保留是为了 forward-compat / 不破坏现有调用方;想真正回复用 [`send_reply`](#send-reply) | +| `response` | string | | **当前 no-op**:handler 接受这个参数但不写库([`tools.ts`](https://github.com/sleep2agi/agent-network/blob/main/server/src/tools.ts) 搜 `"ack_inbox"` 没有读取 `response`)。schema 保留是为了 forward-compat / 不破坏现有调用方;想真正回复用 [`send_reply`](#send-reply) | | `network_id` | string | | Network 范围。utok_ 恰好 1 个成员网络时自动解析,可省略;跨多网络必须显式传(#517) | **返回值**: @@ -203,7 +203,7 @@ report_completion({ **错误**:找不到属于该 alias 的待确认投递 → `message not found or already acknowledged`;投递在查询后不再可写 → `message not found or not yours`。 ::: tip 副作用:tasks 表状态机 -Hub 先用 `id = message_id`,或对任务消息用 `task_id = message_id`,解析出当前未确认的 inbox 行并只 ACK 那一行。若它是任务消息,再用该行解析出的稳定逻辑 `task_id` 把 `tasks` 从 `status='delivered'` UPDATE 到 `'acked'`([`tools.ts:884-920`](https://github.com/sleep2agi/agent-network/blob/main/server/src/tools.ts#L884))。因此 retry/reassign 产生新 inbox `id` 后,仍能 ACK 原任务;旧消费者继续传 inbox `id` 也兼容。任务状态**仅**从 `delivered` 起跳,跟 hub 端 [`send_ack`](#send-ack)(接受 `created` / `delivered`)不同 —— 详见 [Task 生命周期 — `created` 状态](/concepts/task-lifecycle#状态机)。 +Hub 先用 `id = message_id`,或对任务消息用 `task_id = message_id`,解析出当前未确认的 inbox 行并只 ACK 那一行。若它是任务消息,再用该行解析出的稳定逻辑 `task_id` 把 `tasks` 从 `status='delivered'` UPDATE 到 `'acked'`([`tools.ts`](https://github.com/sleep2agi/agent-network/blob/main/server/src/tools.ts) 搜 `UPDATE inbox SET acked = 1 WHERE id = ?1 AND session_name = ?2`)。因此 retry/reassign 产生新 inbox `id` 后,仍能 ACK 原任务;旧消费者继续传 inbox `id` 也兼容。任务状态**仅**从 `delivered` 起跳,跟 hub 端 [`send_ack`](#send-ack)(接受 `created` / `delivered`)不同 —— 详见 [Task 生命周期 — `created` 状态](/concepts/task-lifecycle#状态机)。 ::: --- @@ -212,7 +212,7 @@ Hub 先用 `id = message_id`,或对任务消息用 `task_id = message_id`, ### send_task -> [源码 ↗](https://github.com/sleep2agi/agent-network/blob/main/server/src/tools.ts#L450) +> [源码 ↗](https://github.com/sleep2agi/agent-network/blob/main/server/src/tools.ts) —— 搜 `"send_task"`(注册点在 `server/src/tools.ts`,全仓唯一) 派发任务到指定 Agent 的 inbox。**`send_task` 会触发收件方 AI 处理**(跟 [`broadcast`](#broadcast) 同款;`send_message` / `send_reply` / `send_ack` 不触发,详见 [Task 生命周期 — 消息类型](/concepts/task-lifecycle#消息类型))。 @@ -261,7 +261,7 @@ send_task({ ### send_message -> [源码 ↗](https://github.com/sleep2agi/agent-network/blob/main/server/src/tools.ts#L550) +> [源码 ↗](https://github.com/sleep2agi/agent-network/blob/main/server/src/tools.ts) —— 搜 `"send_message"`(注册点在 `server/src/tools.ts`,全仓唯一) 发消息(不触发 AI 处理,只展示)。 @@ -288,7 +288,7 @@ send_task({ ### send_reply -> [源码 ↗](https://github.com/sleep2agi/agent-network/blob/main/server/src/tools.ts#L589) +> [源码 ↗](https://github.com/sleep2agi/agent-network/blob/main/server/src/tools.ts) —— 搜 `"send_reply"`(注册点在 `server/src/tools.ts`,全仓唯一) 回复任务。关联到原始 task_id,不触发对方 AI 处理。 @@ -317,7 +317,7 @@ send_task({ ### send_ack -> [源码 ↗](https://github.com/sleep2agi/agent-network/blob/main/server/src/tools.ts#L667) +> [源码 ↗](https://github.com/sleep2agi/agent-network/blob/main/server/src/tools.ts) —— 搜 `"send_ack"`(注册点在 `server/src/tools.ts`,全仓唯一) 确认收到任务(轻量级,不入 inbox)。 @@ -343,7 +343,7 @@ send_task({ ### retry_task -> [源码 ↗](https://github.com/sleep2agi/agent-network/blob/main/server/src/tools.ts#L693) +> [源码 ↗](https://github.com/sleep2agi/agent-network/blob/main/server/src/tools.ts) —— 搜 `"retry_task"`(注册点在 `server/src/tools.ts`,全仓唯一) 重试失败/取消/过期的任务。 @@ -366,8 +366,8 @@ send_task({ ``` ::: warning 限制 -- 只能重试状态为 `failed` / `expired` / `cancelled` 的任务(verify [`tools.ts:713`](https://github.com/sleep2agi/agent-network/blob/main/server/src/tools.ts#L713)),其他状态返回 `{ok: false, error: "task status is , not retryable"}` -- 重试会**固定**给一个新的 `+1 小时` TTL([`tools.ts:718`](https://github.com/sleep2agi/agent-network/blob/main/server/src/tools.ts#L718) 硬编码),**不沿用原任务**的 `ttl_seconds` +- 只能重试状态为 `failed` / `expired` / `cancelled` 的任务(verify [`tools.ts`](https://github.com/sleep2agi/agent-network/blob/main/server/src/tools.ts) 搜 `["failed", "expired", "cancelled"].includes(task.status)`),其他状态返回 `{ok: false, error: "task status is , not retryable"}` +- 重试会**固定**给一个新的 `+1 小时` TTL([`tools.ts`](https://github.com/sleep2agi/agent-network/blob/main/server/src/tools.ts) 搜 `+1 hour` 硬编码),**不沿用原任务**的 `ttl_seconds` - `task_id` 不变;inbox 里会插入一条新 `id` 的 row(新 UUID),并 SSE 推 `new_task` 给目标 alias ::: @@ -375,7 +375,7 @@ send_task({ ### cancel_task -> [源码 ↗](https://github.com/sleep2agi/agent-network/blob/main/server/src/tools.ts#L803) +> [源码 ↗](https://github.com/sleep2agi/agent-network/blob/main/server/src/tools.ts) —— 搜 `"cancel_task"`(注册点在 `server/src/tools.ts`,全仓唯一) 取消待处理的任务。 @@ -399,7 +399,7 @@ send_task({ ``` ::: warning 限制 -只能取消状态为 `created` / `delivered` / `acked` / `running` 的任务(4 个 cancellable 源状态,verify [`tools.ts:817`](https://github.com/sleep2agi/agent-network/blob/main/server/src/tools.ts#L817) WHERE 子句)。终态 `replied` / `failed` / `cancelled` / `expired` 上调用此 tool 会返回 `{ok: false, cancelled: false}`。 +只能取消状态为 `created` / `delivered` / `acked` / `running` 的任务(4 个 cancellable 源状态,verify [`tools.ts`](https://github.com/sleep2agi/agent-network/blob/main/server/src/tools.ts) 在 `cancel_task` 段搜 `status IN ('created', 'delivered', 'acked', 'running')`(全仓 2 处,另一处属 `send_message`) WHERE 子句)。终态 `replied` / `failed` / `cancelled` / `expired` 上调用此 tool 会返回 `{ok: false, cancelled: false}`。 `created` 实际只是 DB 默认值,正常 API 路径不会观察到(详见 [Task 生命周期 — `created` 状态](/concepts/task-lifecycle#状态机))。 ::: @@ -408,7 +408,7 @@ send_task({ ### reassign_task -> [源码 ↗](https://github.com/sleep2agi/agent-network/blob/main/server/src/tools.ts#L835) +> [源码 ↗](https://github.com/sleep2agi/agent-network/blob/main/server/src/tools.ts) —— 搜 `"reassign_task"`(注册点在 `server/src/tools.ts`,全仓唯一) 将任务转给另一个 Agent。 @@ -433,9 +433,9 @@ send_task({ ``` ::: warning 限制 -- 只能 reassign **非终态**任务:`created` / `delivered` / `acked` / `running`([`tools.ts:853`](https://github.com/sleep2agi/agent-network/blob/main/server/src/tools.ts#L853) 反向拒掉 `replied` / `failed` / `cancelled` / `expired`,返回 `{ok: false, error: "task is terminal ()"}`) -- 旧 alias 的 inbox row 被 `acked=1`([`tools.ts:858`](https://github.com/sleep2agi/agent-network/blob/main/server/src/tools.ts#L858)),原 agent 不会再 pick up -- 任务 status reset 到 `delivered`,`started_at` 清空,`delivered_at` 刷新到当前 time([`tools.ts:863`](https://github.com/sleep2agi/agent-network/blob/main/server/src/tools.ts#L863))—— 正在 `running` 的任务会被中断 +- 只能 reassign **非终态**任务:`created` / `delivered` / `acked` / `running`([`tools.ts`](https://github.com/sleep2agi/agent-network/blob/main/server/src/tools.ts) 搜 `["replied", "failed", "cancelled", "expired"].includes(task.status)`(全仓唯一) 反向拒掉 `replied` / `failed` / `cancelled` / `expired`,返回 `{ok: false, error: "task is terminal ()"}`) +- 旧 alias 的 inbox row 被 `acked=1`([`tools.ts`](https://github.com/sleep2agi/agent-network/blob/main/server/src/tools.ts) 在 `reassign_task` 段搜 `UPDATE inbox SET acked = 1 WHERE COALESCE(task_id, id) = ?1`(全仓 2 处,另一处属 `cancel_task`)),原 agent 不会再 pick up +- 任务 status reset 到 `delivered`,`started_at` 清空,`delivered_at` 刷新到当前 time([`tools.ts`](https://github.com/sleep2agi/agent-network/blob/main/server/src/tools.ts) 搜 `UPDATE tasks SET to_name = ?1`)—— 正在 `running` 的任务会被中断 - TTL(`expires_at`)**不改**(跟 [`retry_task`](#retry-task) 的「固定 +1h」不同);用原任务剩余时间 - 新 alias 拿到新 UUID 的 inbox row + `new_task` SSE 事件 ::: @@ -446,7 +446,7 @@ send_task({ ### get_task -> [源码 ↗](https://github.com/sleep2agi/agent-network/blob/main/server/src/tools.ts#L740) +> [源码 ↗](https://github.com/sleep2agi/agent-network/blob/main/server/src/tools.ts) —— 搜 `"get_task"`(注册点在 `server/src/tools.ts`,全仓唯一) 查询任务详情。 @@ -479,13 +479,13 @@ send_task({ } ``` -`get_task` 走 `SELECT * FROM tasks`([`tools.ts:749`](https://github.com/sleep2agi/agent-network/blob/main/server/src/tools.ts#L749)),返回**完整行**(上面只是示例字段,实际还含 `requires_response` / `parent_task_id` 等所有列)。任务不存在时返回 `{ok: false, error: "task not found"}`。 +`get_task` 走 `SELECT * FROM tasks`([`tools.ts`](https://github.com/sleep2agi/agent-network/blob/main/server/src/tools.ts) 在 `get_task` 段搜 `SELECT * FROM tasks WHERE task_id = ?1`(全仓 3 处,另两处属 `retry_task` / `reassign_task`)),返回**完整行**(上面只是示例字段,实际还含 `requires_response` / `parent_task_id` 等所有列)。任务不存在时返回 `{ok: false, error: "task not found"}`。 --- ### list_tasks -> [源码 ↗](https://github.com/sleep2agi/agent-network/blob/main/server/src/tools.ts#L763) +> [源码 ↗](https://github.com/sleep2agi/agent-network/blob/main/server/src/tools.ts) —— 搜 `"list_tasks"`(注册点在 `server/src/tools.ts`,全仓唯一) 查询任务列表,支持多维度过滤。 @@ -537,7 +537,7 @@ send_task({ ### get_all_status -> [源码 ↗](https://github.com/sleep2agi/agent-network/blob/main/server/src/tools.ts#L369) +> [源码 ↗](https://github.com/sleep2agi/agent-network/blob/main/server/src/tools.ts) —— 搜 `"get_all_status"`(注册点在 `server/src/tools.ts`,全仓唯一) 获取所有 Session 状态。超过 10 分钟无心跳的自动标记为 offline。 @@ -574,14 +574,14 @@ send_task({ ``` ::: warning `sessions` 行**没有** `model` 字段 -`get_all_status` 走 `SELECT * FROM sessions`([`tools.ts:388`](https://github.com/sleep2agi/agent-network/blob/main/server/src/tools.ts#L388),无 JOIN)。`sessions` 表 schema([`db.ts:7-26`](https://github.com/sleep2agi/agent-network/blob/main/server/src/db.ts#L7) + V2 migration [`db.ts:59-68`](https://github.com/sleep2agi/agent-network/blob/main/server/src/db.ts#L59))**有 `model` 列** —— V2 migration `ALTER TABLE sessions ADD COLUMN model`,且 `report_status` 的 `sessions` upsert 无条件写 `sessions.model = COALESCE(model, 旧值)`([`tools.ts:129`/`141`](https://github.com/sleep2agi/agent-network/blob/main/server/src/tools.ts#L129))。所以 `get_all_status` 直接返回每个 session 的 `model`(agent 没传 `model` 参数时为 `null`)。`nodes` 表里也有一份 `model`(传 `node_id` 时由 `report_status` 同步),是更持久的来源。`summary` 是按 status 分组的全 scope 计数(同 `list_tasks` 的 `stats`)。 +`get_all_status` 走 `SELECT * FROM sessions`([`tools.ts`](https://github.com/sleep2agi/agent-network/blob/main/server/src/tools.ts) 搜 `SELECT * FROM sessions WHERE 1=1`,无 JOIN)。`sessions` 表 schema([`db.ts`](https://github.com/sleep2agi/agent-network/blob/main/server/src/db.ts) 搜 `CREATE TABLE IF NOT EXISTS sessions` + V2 migration [`db.ts`](https://github.com/sleep2agi/agent-network/blob/main/server/src/db.ts) 搜 `ALTER TABLE sessions ADD COLUMN`)**有 `model` 列** —— V2 migration `ALTER TABLE sessions ADD COLUMN model`,且 `report_status` 的 `sessions` upsert 无条件写 `sessions.model = COALESCE(model, 旧值)`([`tools.ts`](https://github.com/sleep2agi/agent-network/blob/main/server/src/tools.ts) 在 `report_status` 段搜 `INSERT INTO sessions`(写这几列的是 `report_status`,不是本节这个 tool) 与 `model = COALESCE(?20, sessions.model)`)。所以 `get_all_status` 直接返回每个 session 的 `model`(agent 没传 `model` 参数时为 `null`)。`nodes` 表里也有一份 `model`(传 `node_id` 时由 `report_status` 同步),是更持久的来源。`summary` 是按 status 分组的全 scope 计数(同 `list_tasks` 的 `stats`)。 ::: --- ### get_session_status -> [源码 ↗](https://github.com/sleep2agi/agent-network/blob/main/server/src/tools.ts#L415) +> [源码 ↗](https://github.com/sleep2agi/agent-network/blob/main/server/src/tools.ts) —— 搜 `"get_session_status"`(注册点在 `server/src/tools.ts`,全仓唯一) 获取单个 Session 的详细状态,包括 inbox 待处理数和最近完成记录。 @@ -619,15 +619,15 @@ send_task({ ``` ::: tip 返回值形状 -- `session` 走 `SELECT * FROM sessions`([`tools.ts:423`](https://github.com/sleep2agi/agent-network/blob/main/server/src/tools.ts#L423)),完整 sessions 行(同 [`get_all_status`](#get-all-status) 的 session 行,**含 `model` 列** —— 见 get_all_status 说明);alias 不存在时 `session` 为 `null` 但 `ok` 仍为 `true` -- `recent_completions` 走 `SELECT * FROM completions ... LIMIT 5`([`tools.ts:433-435`](https://github.com/sleep2agi/agent-network/blob/main/server/src/tools.ts#L433)),完整 9 列 completion 行(`id` / `session_name` / `task` / `result` / `artifacts` / `score` / `duration_minutes` / `network_id` / `completed_at`),按 `completed_at` 倒序最多 5 条 +- `session` 走 `SELECT * FROM sessions`([`tools.ts`](https://github.com/sleep2agi/agent-network/blob/main/server/src/tools.ts) 搜 `SELECT * FROM sessions WHERE alias = ?1`),完整 sessions 行(同 [`get_all_status`](#get-all-status) 的 session 行,**含 `model` 列** —— 见 get_all_status 说明);alias 不存在时 `session` 为 `null` 但 `ok` 仍为 `true` +- `recent_completions` 走 `SELECT * FROM completions ... LIMIT 5`([`tools.ts`](https://github.com/sleep2agi/agent-network/blob/main/server/src/tools.ts) 搜 `SELECT * FROM completions WHERE session_name = ?1`),完整 9 列 completion 行(`id` / `session_name` / `task` / `result` / `artifacts` / `score` / `duration_minutes` / `network_id` / `completed_at`),按 `completed_at` 倒序最多 5 条 ::: --- ### get_completions -> [源码 ↗](https://github.com/sleep2agi/agent-network/blob/main/server/src/tools.ts#L925) +> [源码 ↗](https://github.com/sleep2agi/agent-network/blob/main/server/src/tools.ts) —— 搜 `"get_completions"`(注册点在 `server/src/tools.ts`,全仓唯一) 获取完成记录列表。 @@ -661,7 +661,7 @@ send_task({ } ``` -`completions` 走 `SELECT * FROM completions WHERE completed_at >= `([`tools.ts:938`](https://github.com/sleep2agi/agent-network/blob/main/server/src/tools.ts#L938)),完整 9 列行,按 `completed_at` 倒序。`artifacts` 是 JSON 数组**字符串**(不是已解析的数组 —— `report_completion` 入库时 `JSON.stringify` 过)。`since` 不传默认 cutoff = 24 小时前。 +`completions` 走 `SELECT * FROM completions WHERE completed_at >= `([`tools.ts`](https://github.com/sleep2agi/agent-network/blob/main/server/src/tools.ts) 搜 `SELECT * FROM completions WHERE completed_at >= ?1`),完整 9 列行,按 `completed_at` 倒序。`artifacts` 是 JSON 数组**字符串**(不是已解析的数组 —— `report_completion` 入库时 `JSON.stringify` 过)。`since` 不传默认 cutoff = 24 小时前。 --- @@ -669,11 +669,11 @@ send_task({ ### broadcast -> [源码 ↗](https://github.com/sleep2agi/agent-network/blob/main/server/src/tools.ts#L878) +> [源码 ↗](https://github.com/sleep2agi/agent-network/blob/main/server/src/tools.ts) —— 搜 `"broadcast"`(注册点在 `server/src/tools.ts`,全仓唯一) 向所有在线 Agent 广播消息。**broadcast 与 `task` 同样会触发收件方 AI 处理**([`agent-node/src/cli.ts`](https://github.com/sleep2agi/agent-network/blob/main/agent-node/src/cli.ts) 只对 `task` 和 `broadcast` 类型 think;其余 `reply` / `message` / `ack` 只展示);如果只是想群发通知不要求 AI 回复,用循环 `send_message` 替代。完整消息类型对照见 [Task 生命周期 — 消息类型](/concepts/task-lifecycle#消息类型)。 -**参数**(verify [`server/src/tools.ts:880-885`](https://github.com/sleep2agi/agent-network/blob/main/server/src/tools.ts#L880)): +**参数**(verify [`tools.ts`](https://github.com/sleep2agi/agent-network/blob/main/server/src/tools.ts) 搜 `"Send a message to multiple sessions."`(`broadcast` 的注册描述,全仓唯一;参数 schema 紧随其后)): | 参数 | 类型 | 必需 | 说明 | |------|------|:----:|------| diff --git a/docs-site/docs/api/rest.md b/docs-site/docs/api/rest.md index 0f48403be..64651a0a0 100644 --- a/docs-site/docs/api/rest.md +++ b/docs-site/docs/api/rest.md @@ -141,7 +141,7 @@ curl -X POST http://localhost:9200/api/auth/register \ } ``` -`user` 对象 5 字段对照 [`server/src/auth.ts:7-13`](https://github.com/sleep2agi/agent-network/blob/main/server/src/auth.ts#L7) `AuthUser` interface(`display_name` / `email` 可为 `null`);`token` 是 `utok_` 给 CLI/Dashboard 用,`network_token` 是 `ntok_` 给注册时自动创建的那个网络里的 agent 用。 +`user` 对象 5 字段对照 [`auth.ts`](https://github.com/sleep2agi/agent-network/blob/main/server/src/auth.ts) 搜 `interface AuthUser` `AuthUser` interface(`display_name` / `email` 可为 `null`);`token` 是 `utok_` 给 CLI/Dashboard 用,`network_token` 是 `ntok_` 给注册时自动创建的那个网络里的 agent 用。 **常见 4xx**(verify [`auth.ts register()`](https://github.com/sleep2agi/agent-network/blob/main/server/src/auth.ts)): @@ -194,13 +194,13 @@ curl -X POST http://localhost:9200/api/auth/login \ } ``` -`user` 对象 5 字段同 register 响应(注 `email` 可为 `null`);`network_id` 是该用户作为 owner 的 default network([`auth.ts:113-115`](https://github.com/sleep2agi/agent-network/blob/main/server/src/auth.ts#L113) 取 `ORDER BY role = 'owner' DESC LIMIT 1`)。每次 login 都签发**新的** `utok_`(不撤销已有,多设备登录互不踢,[`auth.ts:102-110`](https://github.com/sleep2agi/agent-network/blob/main/server/src/auth.ts#L102))。 +`user` 对象 5 字段同 register 响应(注 `email` 可为 `null`);`network_id` 是该用户作为 owner 的 default network([`auth.ts:113-115`](https://github.com/sleep2agi/agent-network/blob/main/server/src/auth.ts#L113) 取 `ORDER BY role = 'owner' DESC LIMIT 1`)。每次 login 都签发**新的** `utok_`(不撤销已有,多设备登录互不踢,[`auth.ts`](https://github.com/sleep2agi/agent-network/blob/main/server/src/auth.ts) 搜 `// User token (utok_) — not bound to network, for CLI/Dashboard login`)。 **常见 4xx**(verify [`auth.ts login()`](https://github.com/sleep2agi/agent-network/blob/main/server/src/auth.ts)): | 状态 | `error` 值 | 触发条件 | |------|------------|---------| -| 401 | `invalid username or password` | 用户名不存在 **或** 密码哈希不匹配([`auth.ts:99-100`](https://github.com/sleep2agi/agent-network/blob/main/server/src/auth.ts#L99) 故意把两种错误合并成同一文案,避免 username enumeration);server 同时写 `login_failed` audit | +| 401 | `invalid username or password` | 用户名不存在 **或** 密码哈希不匹配([`auth.ts`](https://github.com/sleep2agi/agent-network/blob/main/server/src/auth.ts) 搜 `invalid username or password`(全仓 2 处) 故意把两种错误合并成同一文案,避免 username enumeration);server 同时写 `login_failed` audit | | 429 | `rate_limited` | 超过 10/分 IP rate limit([`server.ts`](https://github.com/sleep2agi/agent-network/blob/main/server/src/server.ts);触发时写 `login_rate_limited` audit + clientIP)| **速率限制**:10 次/分钟 per IP。 @@ -337,7 +337,7 @@ curl -X POST http://localhost:9200/api/auth/password \ **关键副作用** (verify [`auth.ts:267-282 changePassword + revokeOtherUserTokens`](https://github.com/sleep2agi/agent-network/blob/main/server/src/auth.ts#L267) + [`server.ts`](https://github.com/sleep2agi/agent-network/blob/main/server/src/server.ts)): 1. **当前调用方的 `utok_`** (`resolved.tokenId`) 立即撤销([`server.ts`](https://github.com/sleep2agi/agent-network/blob/main/server/src/server.ts) `revokeToken(...)` 显式删) -2. **其他设备的所有 `utok_` / `atok_`** 同步撤销([`auth.ts:269-270`](https://github.com/sleep2agi/agent-network/blob/main/server/src/auth.ts#L269) `DELETE ... WHERE user_id=? AND network_id IS NULL AND token_id != ?currentTokenId` 一锅端)—— 计数返回到 `revoked` 字段 +2. **其他设备的所有 `utok_` / `atok_`** 同步撤销([`auth.ts`](https://github.com/sleep2agi/agent-network/blob/main/server/src/auth.ts) 搜 `network_id IS NULL AND token_id != ` `DELETE ... WHERE user_id=? AND network_id IS NULL AND token_id != ?currentTokenId` 一锅端)—— 计数返回到 `revoked` 字段 3. **`ntok_` 不受影响**(`revokeOtherUserTokens` 只删 `network_id IS NULL` 的 token,agent node 用 `ntok_` 跑着的不会被改密打断;跟 [account-system 改密码副作用](/guide/account-system#修改密码) ZH 描述一致) 4. **新 `utok_`** (`issued.token`) 颁发给调用方作为响应返回 —— 调用方应立即用新 token 覆盖本地存储 5. 写 audit log: `action='password_changed'` @@ -396,7 +396,7 @@ curl http://localhost:9200/api/networks \ } ``` -`networks` 数组每行 10 字段:9 个 `networks` 表字段 ([`server/src/db.ts:168-177`](https://github.com/sleep2agi/agent-network/blob/main/server/src/db.ts#L168) 含 v3 migration `visibility` + `max_members`) + 1 个 join 字段 `member_role`([`auth.ts:382-388`](https://github.com/sleep2agi/agent-network/blob/main/server/src/auth.ts#L382) JOIN `network_members`)。排序:owner 在前,其余按 `created_at`(`ORDER BY nm.role = 'owner' DESC, n.created_at`)。`settings` / `description` 可为 `null`。`ntok_` 调用只返回当前 binding 那一个 network(不是全部);`utok_` 返回所有所属网络。 +`networks` 数组每行 10 字段:9 个 `networks` 表字段 ([`db.ts`](https://github.com/sleep2agi/agent-network/blob/main/server/src/db.ts) 搜 `CREATE TABLE IF NOT EXISTS networks` 含 v3 migration `visibility` + `max_members`) + 1 个 join 字段 `member_role`([`auth.ts:382-388`](https://github.com/sleep2agi/agent-network/blob/main/server/src/auth.ts#L382) JOIN `network_members`)。排序:owner 在前,其余按 `created_at`(`ORDER BY nm.role = 'owner' DESC, n.created_at`)。`settings` / `description` 可为 `null`。`ntok_` 调用只返回当前 binding 那一个 network(不是全部);`utok_` 返回所有所属网络。 --- @@ -1559,7 +1559,7 @@ curl -X POST http://localhost:9200/mcp \ > [源码 ↗](https://github.com/sleep2agi/agent-network/blob/main/server/src/server.ts) -SSE 实时推送端点,客户端通过长连接接收事件。路径段 `:name` 是一个**通用 channel 名**(源码里叫 `:session`):Agent 用自己的 **node alias** 订阅、Dashboard 用 **username** 订阅 user channel。SSE 层本身是 per-channel-name 的 `Map`([`push.ts:11` `clients`](https://github.com/sleep2agi/agent-network/blob/main/server/src/push.ts#L11)),不区分 alias / username —— `pushEvent(name, ...)` 推给谁取决于谁注册了那个 name(如 `node.renamed` 同时推 alias 流和成员 username channel,见下表)。 +SSE 实时推送端点,客户端通过长连接接收事件。路径段 `:name` 是一个**通用 channel 名**(源码里叫 `:session`):Agent 用自己的 **node alias** 订阅、Dashboard 用 **username** 订阅 user channel。SSE 层本身是 per-channel-name 的 `Map`([`push.ts`](https://github.com/sleep2agi/agent-network/blob/main/server/src/push.ts) 搜 `const clients = new Map()`),不区分 alias / username —— `pushEvent(name, ...)` 推给谁取决于谁注册了那个 name(如 `node.renamed` 同时推 alias 流和成员 username channel,见下表)。 ```bash # 推荐:Authorization header(避免 token 写进代理 / 浏览器历史 / access log) @@ -1573,7 +1573,7 @@ curl -N "http://localhost:9200/events/代码1号?token=ntok_xxx" | 事件 | 触发条件 | 数据 | |------|---------|------| -| `connected` | 初始连接握手([`push.ts:35`](https://github.com/sleep2agi/agent-network/blob/main/server/src/push.ts#L35),每个 client 连上 SSE 时发一次) | `{session, network_id}` | +| `connected` | 初始连接握手([`push.ts`](https://github.com/sleep2agi/agent-network/blob/main/server/src/push.ts) 搜 `{ type: "connected", session: sessionName`,每个 client 连上 SSE 时发一次) | `{session, network_id}` | | `new_task` | 收到新任务(`send_task` / `retry_task` / `reassign_task` / REST `POST /api/task`) | `{inbox_count, priority, from}` | | `new_message` | 收到新消息(`send_message`) | `{from, message_id}` | | `new_reply` | 收到 reply(`send_reply`) | `{from, message_id, in_reply_to, status}` | @@ -1711,7 +1711,7 @@ curl -X POST http://localhost:9200/api/auth/tokens \ ::: ::: info 这个 endpoint 创建的是 legacy `atok_` -本 endpoint 走 [`auth.ts:243` `generateToken()`](https://github.com/sleep2agi/agent-network/blob/main/server/src/auth.ts#L243) 颁发 `atok_` 前缀 + `scope='full'` token,是 V2 时代的兼容路径,不是 v0.8 主线的 `utok_` / `ntok_`。新代码请用: +本 endpoint 走 [`auth.ts`](https://github.com/sleep2agi/agent-network/blob/main/server/src/auth.ts) 搜 `generateToken`(全仓 3 处) 颁发 `atok_` 前缀 + `scope='full'` token,是 V2 时代的兼容路径,不是 v0.8 主线的 `utok_` / `ntok_`。新代码请用: - **`utok_`(用户 Token)**:通过 [POST /api/auth/login](#post-api-auth-login) 或 [POST /api/auth/register](#post-api-auth-register) 自动颁发 - **`ntok_`(节点 Token)**:通过 [POST /api/auth/node-token](#post-api-auth-node-token) 创建(绑定到指定 network + 节点 alias) @@ -1997,7 +1997,7 @@ curl -X POST http://localhost:9200/api/networks/join \ | 400 | `invite code expired` | `expires_at < now()`(不传 `expires_days` 创建则不会过期) | | 400 | `already a member of this network` | 调用者已是该网络成员 | -`anet network join` CLI 拿到该响应后会自动切换到加入的 network(即 `~/.anet/config.json` 的 `network_id` 字段更新为 `res.network_id`),并打印 `Joined network as `。同时 server 自动颁发一个 `network_id` 绑定的 token 给加入者([`auth.ts:374-377`](https://github.com/sleep2agi/agent-network/blob/main/server/src/auth.ts#L374) `name='auto-join' scope='full'`),写 audit `network_joined`。 +`anet network join` CLI 拿到该响应后会自动切换到加入的 network(即 `~/.anet/config.json` 的 `network_id` 字段更新为 `res.network_id`),并打印 `Joined network as `。同时 server 自动颁发一个 `network_id` 绑定的 token 给加入者([`auth.ts`](https://github.com/sleep2agi/agent-network/blob/main/server/src/auth.ts) 搜 `"auto-join", "full"` `name='auto-join' scope='full'`),写 audit `network_joined`。 --- diff --git a/docs-site/docs/en/api/mcp-tools.md b/docs-site/docs/en/api/mcp-tools.md index 0f14177ca..d2798b294 100644 --- a/docs-site/docs/en/api/mcp-tools.md +++ b/docs-site/docs/en/api/mcp-tools.md @@ -22,7 +22,7 @@ CommHub Server registers ~40 MCP Tools in total; this page documents the 17 core ### report_status -> [View source ↗](https://github.com/sleep2agi/agent-network/blob/main/server/src/tools.ts#L88) +> [View source ↗](https://github.com/sleep2agi/agent-network/blob/main/server/src/tools.ts) —— grep for `"report_status"` in `server/src/tools.ts` (single registration site) Report agent status. Also serves as a heartbeat (recommended every 3 minutes). @@ -43,7 +43,7 @@ Report agent status. Also serves as a heartbeat (recommended every 3 minutes). | `project_dir` | string | | Working directory | | `version` | string | | Agent version | | `tmux_name` | string | | tmux session name | -| `node_id` | string | | Stable node identifier. **Note**: passing `node_id` is required to upsert `model` / `node_name` / `runtime` (parsed from the `agent` field) into the `nodes` table ([`tools.ts:168-188`](https://github.com/sleep2agi/agent-network/blob/main/server/src/tools.ts#L168)). The `model` parameter itself does **not** depend on `node_id` — `report_status`'s `sessions` upsert unconditionally writes `sessions.model = COALESCE(model, old)` ([`tools.ts:129` INSERT + `tools.ts:141` ON CONFLICT](https://github.com/sleep2agi/agent-network/blob/main/server/src/tools.ts#L129)); only `node_name` has no `sessions` column and must go through the `nodes` table via `node_id`. | +| `node_id` | string | | Stable node identifier. **Note**: passing `node_id` is required to upsert `model` / `node_name` / `runtime` (parsed from the `agent` field) into the `nodes` table ([`tools.ts`](https://github.com/sleep2agi/agent-network/blob/main/server/src/tools.ts) — grep `upsertNodeWithSec1Guard` (the call inside `report_status`'s `if (node_id)`, plus the helper of the same name defined after registerTools)). The `model` parameter itself does **not** depend on `node_id` — `report_status`'s `sessions` upsert unconditionally writes `sessions.model = COALESCE(model, old)` ([`tools.ts`](https://github.com/sleep2agi/agent-network/blob/main/server/src/tools.ts) — grep `INSERT INTO sessions` inside `report_status` (these columns are written by `report_status`, not by the tool documented in this section) and `model = COALESCE(?20, sessions.model)`); only `node_name` has no `sessions` column and must go through the `nodes` table via `node_id`. | | `session_id` | string | | Runtime session/thread ID | | `config_path` | string | | Config file path | | `channels` | string | | Channel list (JSON array string) | @@ -77,11 +77,11 @@ report_status({ ``` ::: warning Authentication required -This tool only accepts a **`ntok_` (network-scoped) token**. Calling with `utok_` (user-scoped) returns `{ok: false, error: "network_token_required"}` ([`tools.ts:116-118`](https://github.com/sleep2agi/agent-network/blob/main/server/src/tools.ts#L116)). This is a hard constraint after RFC-001 in v0.8 — agent heartbeats must be bound to a network. +This tool only accepts a **`ntok_` (network-scoped) token**. Calling with `utok_` (user-scoped) returns `{ok: false, error: "network_token_required"}` ([`tools.ts`](https://github.com/sleep2agi/agent-network/blob/main/server/src/tools.ts) — grep `"network_token_required"` (3 sites)). This is a hard constraint after RFC-001 in v0.8 — agent heartbeats must be bound to a network. Side effects beyond the `sessions` table: -- Automatically DELETEs any older session row with the same network + alias + a different `resume_id` ([`tools.ts:127`](https://github.com/sleep2agi/agent-network/blob/main/server/src/tools.ts#L127); cleans up orphans across agent restarts) -- When `status="working"` with a `task`, transitions the corresponding `tasks` row from `delivered`/`acked` to `running` ([`tools.ts:150-153`](https://github.com/sleep2agi/agent-network/blob/main/server/src/tools.ts#L150); see [Task lifecycle](/en/concepts/task-lifecycle#state-machine)) +- Automatically DELETEs any older session row with the same network + alias + a different `resume_id` ([`tools.ts`](https://github.com/sleep2agi/agent-network/blob/main/server/src/tools.ts) — grep `DELETE FROM sessions WHERE alias = ?1 AND resume_id != ?2`; cleans up orphans across agent restarts) +- When `status="working"` with a `task`, transitions the corresponding `tasks` row from `delivered`/`acked` to `running` ([`tools.ts`](https://github.com/sleep2agi/agent-network/blob/main/server/src/tools.ts) — grep `UPDATE tasks SET status = 'running'`; see [Task lifecycle](/en/concepts/task-lifecycle#state-machine)) - When `node_id` is passed, upserts the `nodes` table (including `model` / `node_name` / `runtime`; see the `node_id` row above) ::: @@ -89,7 +89,7 @@ Side effects beyond the `sessions` table: ### report_completion -> [View source ↗](https://github.com/sleep2agi/agent-network/blob/main/server/src/tools.ts#L213) +> [View source ↗](https://github.com/sleep2agi/agent-network/blob/main/server/src/tools.ts) —— grep for `"report_completion"` in `server/src/tools.ts` (single registration site) Report task completion. Automatically updates session status to idle. @@ -128,11 +128,11 @@ report_completion({ ``` ::: tip Side effects (beyond the `completions` INSERT) -- **Session state flip**: [`tools.ts:239-242`](https://github.com/sleep2agi/agent-network/blob/main/server/src/tools.ts#L239) `UPDATE sessions SET status='idle', task=NULL, progress=0` (matched by alias) -- **Task state transition**: [`tools.ts:244-266`](https://github.com/sleep2agi/agent-network/blob/main/server/src/tools.ts#L244) moves the `tasks` row from `delivered`/`acked`/`running` to `replied`. First tries `task_id = `; on miss it falls back to `to_name= AND content=` — so the `task` parameter can be either the **real task_id** (preferred) or the **task description string** (fallback) -- **`result` truncation**: only the first 4000 chars are written to `tasks.result` ([`tools.ts:246`](https://github.com/sleep2agi/agent-network/blob/main/server/src/tools.ts#L246)); the full `result` still lands in `completions.result` -- **chained_reply auto-propagation**: if the task has a `parent_task_id`, the parent's originator gets a `chained_reply` SSE event ([`tools.ts:271-291`](https://github.com/sleep2agi/agent-network/blob/main/server/src/tools.ts#L271); used so subtask replies bubble up to the parent — see [`task-lifecycle` dual-write](/en/concepts/task-lifecycle#dual-write-mechanism)) -- **`task_events` log**: a `replied` event is logged ([`tools.ts:270`](https://github.com/sleep2agi/agent-network/blob/main/server/src/tools.ts#L270)) +- **Session state flip**: [`tools.ts`](https://github.com/sleep2agi/agent-network/blob/main/server/src/tools.ts) — grep `UPDATE sessions SET status = 'idle'` `UPDATE sessions SET status='idle', task=NULL, progress=0` (matched by alias) +- **Task state transition**: [`tools.ts`](https://github.com/sleep2agi/agent-network/blob/main/server/src/tools.ts) — grep `UPDATE tasks SET status = 'replied'` (2 sites) moves the `tasks` row from `delivered`/`acked`/`running` to `replied`. First tries `task_id = `; on miss it falls back to `to_name= AND content=` — so the `task` parameter can be either the **real task_id** (preferred) or the **task description string** (fallback) +- **`result` truncation**: only the first 4000 chars are written to `tasks.result` ([`tools.ts`](https://github.com/sleep2agi/agent-network/blob/main/server/src/tools.ts) — grep `result.slice(0, 4000)` (2 sites)); the full `result` still lands in `completions.result` +- **chained_reply auto-propagation**: if the task has a `parent_task_id`, the parent's originator gets a `chained_reply` SSE event ([`tools.ts`](https://github.com/sleep2agi/agent-network/blob/main/server/src/tools.ts) — grep `type: "chained_reply"` (2 sites); used so subtask replies bubble up to the parent — see [`task-lifecycle` dual-write](/en/concepts/task-lifecycle#dual-write-mechanism)) +- **`task_events` log**: a `replied` event is logged ([`tools.ts`](https://github.com/sleep2agi/agent-network/blob/main/server/src/tools.ts) — grep `logTaskEvent(updatedTaskId, null, "replied"`) Compared to [`send_reply`](#send-reply): `send_reply` is a hub tool that requires an explicit `task_id`; `report_completion` is an agent tool that can fall back by content match. ::: @@ -141,7 +141,7 @@ Compared to [`send_reply`](#send-reply): `send_reply` is a hub tool that require ### get_inbox -> [View source ↗](https://github.com/sleep2agi/agent-network/blob/main/server/src/tools.ts#L837) +> [View source ↗](https://github.com/sleep2agi/agent-network/blob/main/server/src/tools.ts) —— grep for `"get_inbox"` in `server/src/tools.ts` (single registration site) Fetch pending messages. @@ -181,7 +181,7 @@ Messages are sorted by priority: high > normal > low, then by time within the sa ### ack_inbox -> [View source ↗](https://github.com/sleep2agi/agent-network/blob/main/server/src/tools.ts#L871) +> [View source ↗](https://github.com/sleep2agi/agent-network/blob/main/server/src/tools.ts) —— grep for `"ack_inbox"` in `server/src/tools.ts` (single registration site) Acknowledge message receipt. After ACK, the message won't be returned by `get_inbox`. @@ -191,7 +191,7 @@ Acknowledge message receipt. After ACK, the message won't be returned by `get_in |------|------|:----:|------| | `alias` | string | ✓ | Session alias | | `message_id` | string | ✓ | The inbox delivery-row `id`, or a task message's logical `task_id`. Task consumers should prefer the `task_id` returned by `get_inbox`; use `id` for non-task messages. | -| `response` | string | | **Currently a no-op**: the handler accepts this parameter but never writes it to the database ([`tools.ts:872-924`](https://github.com/sleep2agi/agent-network/blob/main/server/src/tools.ts#L872) does not read `response`). The schema is kept for forward-compat / to avoid breaking existing callers; if you want to actually reply, use [`send_reply`](#send-reply). | +| `response` | string | | **Currently a no-op**: the handler accepts this parameter but never writes it to the database ([`tools.ts`](https://github.com/sleep2agi/agent-network/blob/main/server/src/tools.ts) — grep `"ack_inbox"` does not read `response`). The schema is kept for forward-compat / to avoid breaking existing callers; if you want to actually reply, use [`send_reply`](#send-reply). | | `network_id` | string | | Network scope. Auto-resolved for utok_ callers with exactly one membership — optional then; required when the caller spans multiple networks (#517) | **Response**: @@ -203,7 +203,7 @@ Acknowledge message receipt. After ACK, the message won't be returned by `get_in **Errors**: no pending delivery owned by the alias → `message not found or already acknowledged`; the resolved delivery becomes unwritable after lookup → `message not found or not yours`. ::: tip Side effect: tasks-table state machine -The Hub first resolves the current unacknowledged inbox row by `id = message_id`, or for a task message by `task_id = message_id`, and ACKs only that row. For task messages it then uses the row's resolved stable logical `task_id` to UPDATE the matching `tasks` row from `status='delivered'` to `'acked'` ([`tools.ts:884-920`](https://github.com/sleep2agi/agent-network/blob/main/server/src/tools.ts#L884)). Retry/reassign deliveries can therefore have a new inbox `id` while still ACKing the original task; legacy callers that pass an inbox `id` remain compatible. The task transition **only** accepts `delivered`, unlike the hub-side [`send_ack`](#send-ack), which also accepts `created` — see [Task lifecycle — the `created` state](/en/concepts/task-lifecycle#state-machine). +The Hub first resolves the current unacknowledged inbox row by `id = message_id`, or for a task message by `task_id = message_id`, and ACKs only that row. For task messages it then uses the row's resolved stable logical `task_id` to UPDATE the matching `tasks` row from `status='delivered'` to `'acked'` ([`tools.ts`](https://github.com/sleep2agi/agent-network/blob/main/server/src/tools.ts) — grep `UPDATE inbox SET acked = 1 WHERE id = ?1 AND session_name = ?2`). Retry/reassign deliveries can therefore have a new inbox `id` while still ACKing the original task; legacy callers that pass an inbox `id` remain compatible. The task transition **only** accepts `delivered`, unlike the hub-side [`send_ack`](#send-ack), which also accepts `created` — see [Task lifecycle — the `created` state](/en/concepts/task-lifecycle#state-machine). ::: --- @@ -212,7 +212,7 @@ The Hub first resolves the current unacknowledged inbox row by `id = message_id` ### send_task -> [View source ↗](https://github.com/sleep2agi/agent-network/blob/main/server/src/tools.ts#L450) +> [View source ↗](https://github.com/sleep2agi/agent-network/blob/main/server/src/tools.ts) —— grep for `"send_task"` in `server/src/tools.ts` (single registration site) Dispatch a task to a specified agent's inbox. **`send_task` triggers AI processing on the receiver** (same as [`broadcast`](#broadcast); `send_message` / `send_reply` / `send_ack` do not — see [Task lifecycle — Message types](/en/concepts/task-lifecycle#message-types)). @@ -261,7 +261,7 @@ send_task({ ### send_message -> [View source ↗](https://github.com/sleep2agi/agent-network/blob/main/server/src/tools.ts#L550) +> [View source ↗](https://github.com/sleep2agi/agent-network/blob/main/server/src/tools.ts) —— grep for `"send_message"` in `server/src/tools.ts` (single registration site) Send a message (does not trigger AI processing, display only). @@ -288,7 +288,7 @@ Send a message (does not trigger AI processing, display only). ### send_reply -> [View source ↗](https://github.com/sleep2agi/agent-network/blob/main/server/src/tools.ts#L589) +> [View source ↗](https://github.com/sleep2agi/agent-network/blob/main/server/src/tools.ts) —— grep for `"send_reply"` in `server/src/tools.ts` (single registration site) Reply to a task. Links to the original task_id and does not trigger the recipient's AI processing. @@ -317,7 +317,7 @@ Reply to a task. Links to the original task_id and does not trigger the recipien ### send_ack -> [View source ↗](https://github.com/sleep2agi/agent-network/blob/main/server/src/tools.ts#L667) +> [View source ↗](https://github.com/sleep2agi/agent-network/blob/main/server/src/tools.ts) —— grep for `"send_ack"` in `server/src/tools.ts` (single registration site) Acknowledge task receipt (lightweight, does not enter inbox). @@ -343,7 +343,7 @@ Acknowledge task receipt (lightweight, does not enter inbox). ### retry_task -> [View source ↗](https://github.com/sleep2agi/agent-network/blob/main/server/src/tools.ts#L693) +> [View source ↗](https://github.com/sleep2agi/agent-network/blob/main/server/src/tools.ts) —— grep for `"retry_task"` in `server/src/tools.ts` (single registration site) Retry a failed/cancelled/expired task. @@ -366,8 +366,8 @@ Retry a failed/cancelled/expired task. ``` ::: warning Limitation -- Can only retry tasks with status `failed` / `expired` / `cancelled` (verify [`tools.ts:713`](https://github.com/sleep2agi/agent-network/blob/main/server/src/tools.ts#L713)); other statuses return `{ok: false, error: "task status is , not retryable"}` -- Retry gives the task a **fresh `+1 hour` TTL** (hardcoded at [`tools.ts:718`](https://github.com/sleep2agi/agent-network/blob/main/server/src/tools.ts#L718)) — the original task's `ttl_seconds` is **not preserved** +- Can only retry tasks with status `failed` / `expired` / `cancelled` (verify [`tools.ts`](https://github.com/sleep2agi/agent-network/blob/main/server/src/tools.ts) — grep `["failed", "expired", "cancelled"].includes(task.status)`); other statuses return `{ok: false, error: "task status is , not retryable"}` +- Retry gives the task a **fresh `+1 hour` TTL** (hardcoded at [`tools.ts`](https://github.com/sleep2agi/agent-network/blob/main/server/src/tools.ts) — grep `+1 hour`) — the original task's `ttl_seconds` is **not preserved** - `task_id` is reused; a new inbox row (new UUID) is inserted and a `new_task` SSE event is pushed to the target alias ::: @@ -375,7 +375,7 @@ Retry a failed/cancelled/expired task. ### cancel_task -> [View source ↗](https://github.com/sleep2agi/agent-network/blob/main/server/src/tools.ts#L803) +> [View source ↗](https://github.com/sleep2agi/agent-network/blob/main/server/src/tools.ts) —— grep for `"cancel_task"` in `server/src/tools.ts` (single registration site) Cancel a pending task. @@ -399,7 +399,7 @@ Cancel a pending task. ``` ::: warning Constraint -Only cancellable from these 4 source statuses: `created` / `delivered` / `acked` / `running` (verify the WHERE clause at [`tools.ts:817`](https://github.com/sleep2agi/agent-network/blob/main/server/src/tools.ts#L817)). Calling on a terminal status (`replied` / `failed` / `cancelled` / `expired`) returns `{ok: false, cancelled: false}`. +Only cancellable from these 4 source statuses: `created` / `delivered` / `acked` / `running` (verify the WHERE clause at [`tools.ts`](https://github.com/sleep2agi/agent-network/blob/main/server/src/tools.ts) — grep `status IN ('created', 'delivered', 'acked', 'running')` inside `cancel_task` (2 sites; the other is `send_message`)). Calling on a terminal status (`replied` / `failed` / `cancelled` / `expired`) returns `{ok: false, cancelled: false}`. `created` is only the DB column default; the normal API path never produces a row in that state (see [Task lifecycle — the `created` state](/en/concepts/task-lifecycle#state-machine)). ::: @@ -408,7 +408,7 @@ Only cancellable from these 4 source statuses: `created` / `delivered` / `acked` ### reassign_task -> [View source ↗](https://github.com/sleep2agi/agent-network/blob/main/server/src/tools.ts#L835) +> [View source ↗](https://github.com/sleep2agi/agent-network/blob/main/server/src/tools.ts) —— grep for `"reassign_task"` in `server/src/tools.ts` (single registration site) Reassign a task to another agent. @@ -433,9 +433,9 @@ Reassign a task to another agent. ``` ::: warning Constraint -- Reassign works only on **non-terminal** tasks: `created` / `delivered` / `acked` / `running` ([`tools.ts:853`](https://github.com/sleep2agi/agent-network/blob/main/server/src/tools.ts#L853) rejects `replied` / `failed` / `cancelled` / `expired` with `{ok: false, error: "task is terminal ()"}`) -- The old alias's inbox row is `acked=1` ([`tools.ts:858`](https://github.com/sleep2agi/agent-network/blob/main/server/src/tools.ts#L858)) so the original agent will not pick it up -- Task status resets to `delivered`, `started_at` clears, `delivered_at` refreshes to now ([`tools.ts:863`](https://github.com/sleep2agi/agent-network/blob/main/server/src/tools.ts#L863)) — a `running` task is interrupted +- Reassign works only on **non-terminal** tasks: `created` / `delivered` / `acked` / `running` ([`tools.ts`](https://github.com/sleep2agi/agent-network/blob/main/server/src/tools.ts) — grep `["replied", "failed", "cancelled", "expired"].includes(task.status)` (unique) rejects `replied` / `failed` / `cancelled` / `expired` with `{ok: false, error: "task is terminal ()"}`) +- The old alias's inbox row is `acked=1` ([`tools.ts`](https://github.com/sleep2agi/agent-network/blob/main/server/src/tools.ts) — grep `UPDATE inbox SET acked = 1 WHERE COALESCE(task_id, id) = ?1` inside `reassign_task` (2 sites; the other is `cancel_task`)) so the original agent will not pick it up +- Task status resets to `delivered`, `started_at` clears, `delivered_at` refreshes to now ([`tools.ts`](https://github.com/sleep2agi/agent-network/blob/main/server/src/tools.ts) — grep `UPDATE tasks SET to_name = ?1`) — a `running` task is interrupted - TTL (`expires_at`) is **not modified** (unlike [`retry_task`](#retry-task) which forces `+1 hour`); the task keeps its remaining time - The new alias receives a fresh-UUID inbox row + a `new_task` SSE event ::: @@ -446,7 +446,7 @@ Reassign a task to another agent. ### get_task -> [View source ↗](https://github.com/sleep2agi/agent-network/blob/main/server/src/tools.ts#L740) +> [View source ↗](https://github.com/sleep2agi/agent-network/blob/main/server/src/tools.ts) —— grep for `"get_task"` in `server/src/tools.ts` (single registration site) Query task details. @@ -479,13 +479,13 @@ Query task details. } ``` -`get_task` does `SELECT * FROM tasks` ([`tools.ts:749`](https://github.com/sleep2agi/agent-network/blob/main/server/src/tools.ts#L749)) and returns the **full row** (the example above shows sample fields; the actual row also includes `requires_response` / `parent_task_id` and every other column). When the task doesn't exist it returns `{ok: false, error: "task not found"}`. +`get_task` does `SELECT * FROM tasks` ([`tools.ts`](https://github.com/sleep2agi/agent-network/blob/main/server/src/tools.ts) — grep `SELECT * FROM tasks WHERE task_id = ?1` inside `get_task` (3 sites; the others are `retry_task` / `reassign_task`)) and returns the **full row** (the example above shows sample fields; the actual row also includes `requires_response` / `parent_task_id` and every other column). When the task doesn't exist it returns `{ok: false, error: "task not found"}`. --- ### list_tasks -> [View source ↗](https://github.com/sleep2agi/agent-network/blob/main/server/src/tools.ts#L763) +> [View source ↗](https://github.com/sleep2agi/agent-network/blob/main/server/src/tools.ts) —— grep for `"list_tasks"` in `server/src/tools.ts` (single registration site) Query task list with multi-dimensional filtering. @@ -537,7 +537,7 @@ Each `list_tasks` row includes task identity, sender/recipient, status, content/ ### get_all_status -> [View source ↗](https://github.com/sleep2agi/agent-network/blob/main/server/src/tools.ts#L369) +> [View source ↗](https://github.com/sleep2agi/agent-network/blob/main/server/src/tools.ts) —— grep for `"get_all_status"` in `server/src/tools.ts` (single registration site) Get all session statuses. Sessions without a heartbeat for over 10 minutes are auto-marked offline. @@ -574,14 +574,14 @@ Get all session statuses. Sessions without a heartbeat for over 10 minutes are a ``` ::: warning The `sessions` row has **no `model` field** -`get_all_status` runs `SELECT * FROM sessions` ([`tools.ts:388`](https://github.com/sleep2agi/agent-network/blob/main/server/src/tools.ts#L388), no JOIN). The `sessions` table schema ([`db.ts:7-26`](https://github.com/sleep2agi/agent-network/blob/main/server/src/db.ts#L7) + V2 migration [`db.ts:59-68`](https://github.com/sleep2agi/agent-network/blob/main/server/src/db.ts#L59)) **has a `model` column** — the V2 migration runs `ALTER TABLE sessions ADD COLUMN model`, and `report_status`'s `sessions` upsert unconditionally writes `sessions.model = COALESCE(model, old)` ([`tools.ts:129`/`141`](https://github.com/sleep2agi/agent-network/blob/main/server/src/tools.ts#L129)). So `get_all_status` returns each session's `model` directly (`null` if the agent never passed a `model` parameter). The `nodes` table also keeps a copy of `model` (synced by `report_status` when `node_id` is passed) as the more durable source. `summary` is the status-grouped count over the entire scope (same as `list_tasks`'s `stats`). +`get_all_status` runs `SELECT * FROM sessions` ([`tools.ts`](https://github.com/sleep2agi/agent-network/blob/main/server/src/tools.ts) — grep `SELECT * FROM sessions WHERE 1=1`, no JOIN). The `sessions` table schema ([`db.ts`](https://github.com/sleep2agi/agent-network/blob/main/server/src/db.ts) — grep `CREATE TABLE IF NOT EXISTS sessions` + V2 migration [`db.ts`](https://github.com/sleep2agi/agent-network/blob/main/server/src/db.ts) — grep `ALTER TABLE sessions ADD COLUMN`) **has a `model` column** — the V2 migration runs `ALTER TABLE sessions ADD COLUMN model`, and `report_status`'s `sessions` upsert unconditionally writes `sessions.model = COALESCE(model, old)` ([`tools.ts`](https://github.com/sleep2agi/agent-network/blob/main/server/src/tools.ts) — grep `INSERT INTO sessions` inside `report_status` (these columns are written by `report_status`, not by the tool documented in this section) and `model = COALESCE(?20, sessions.model)`). So `get_all_status` returns each session's `model` directly (`null` if the agent never passed a `model` parameter). The `nodes` table also keeps a copy of `model` (synced by `report_status` when `node_id` is passed) as the more durable source. `summary` is the status-grouped count over the entire scope (same as `list_tasks`'s `stats`). ::: --- ### get_session_status -> [View source ↗](https://github.com/sleep2agi/agent-network/blob/main/server/src/tools.ts#L415) +> [View source ↗](https://github.com/sleep2agi/agent-network/blob/main/server/src/tools.ts) —— grep for `"get_session_status"` in `server/src/tools.ts` (single registration site) Get detailed status of a single session, including pending inbox count and recent completions. @@ -619,15 +619,15 @@ Get detailed status of a single session, including pending inbox count and recen ``` ::: tip Response shape -- `session` is `SELECT * FROM sessions` ([`tools.ts:423`](https://github.com/sleep2agi/agent-network/blob/main/server/src/tools.ts#L423)) — the full sessions row (same as [`get_all_status`](#get-all-status)'s session row, **including the `model` column** — see the get_all_status note); if the alias doesn't exist `session` is `null` but `ok` is still `true` -- `recent_completions` is `SELECT * FROM completions ... LIMIT 5` ([`tools.ts:433-435`](https://github.com/sleep2agi/agent-network/blob/main/server/src/tools.ts#L433)) — the full 9-column completion row (`id` / `session_name` / `task` / `result` / `artifacts` / `score` / `duration_minutes` / `network_id` / `completed_at`), ordered by `completed_at` DESC, max 5 +- `session` is `SELECT * FROM sessions` ([`tools.ts`](https://github.com/sleep2agi/agent-network/blob/main/server/src/tools.ts) — grep `SELECT * FROM sessions WHERE alias = ?1`) — the full sessions row (same as [`get_all_status`](#get-all-status)'s session row, **including the `model` column** — see the get_all_status note); if the alias doesn't exist `session` is `null` but `ok` is still `true` +- `recent_completions` is `SELECT * FROM completions ... LIMIT 5` ([`tools.ts`](https://github.com/sleep2agi/agent-network/blob/main/server/src/tools.ts) — grep `SELECT * FROM completions WHERE session_name = ?1`) — the full 9-column completion row (`id` / `session_name` / `task` / `result` / `artifacts` / `score` / `duration_minutes` / `network_id` / `completed_at`), ordered by `completed_at` DESC, max 5 ::: --- ### get_completions -> [View source ↗](https://github.com/sleep2agi/agent-network/blob/main/server/src/tools.ts#L925) +> [View source ↗](https://github.com/sleep2agi/agent-network/blob/main/server/src/tools.ts) —— grep for `"get_completions"` in `server/src/tools.ts` (single registration site) Get completion records. @@ -661,7 +661,7 @@ Get completion records. } ``` -`completions` is `SELECT * FROM completions WHERE completed_at >= ` ([`tools.ts:938`](https://github.com/sleep2agi/agent-network/blob/main/server/src/tools.ts#L938)) — the full 9-column row, ordered by `completed_at` DESC. `artifacts` is a JSON-array **string** (not a parsed array — `report_completion` `JSON.stringify`s it on the way in). When `since` is omitted the cutoff defaults to 24 hours ago. +`completions` is `SELECT * FROM completions WHERE completed_at >= ` ([`tools.ts`](https://github.com/sleep2agi/agent-network/blob/main/server/src/tools.ts) — grep `SELECT * FROM completions WHERE completed_at >= ?1`) — the full 9-column row, ordered by `completed_at` DESC. `artifacts` is a JSON-array **string** (not a parsed array — `report_completion` `JSON.stringify`s it on the way in). When `since` is omitted the cutoff defaults to 24 hours ago. --- @@ -669,11 +669,11 @@ Get completion records. ### broadcast -> [View source ↗](https://github.com/sleep2agi/agent-network/blob/main/server/src/tools.ts#L878) +> [View source ↗](https://github.com/sleep2agi/agent-network/blob/main/server/src/tools.ts) —— grep for `"broadcast"` in `server/src/tools.ts` (single registration site) Broadcast a message to all online agents. **`broadcast` triggers AI processing on receivers, the same as `task`** ([`agent-node/src/cli.ts`](https://github.com/sleep2agi/agent-network/blob/main/agent-node/src/cli.ts) thinks only on `task` and `broadcast` types; `reply` / `message` / `ack` are display-only). If you just want a notification without an AI reply, loop `send_message` instead. Full message-type table: [Task lifecycle — Message types](/en/concepts/task-lifecycle#message-types). -**Parameters** (verify [`server/src/tools.ts:880-885`](https://github.com/sleep2agi/agent-network/blob/main/server/src/tools.ts#L880)): +**Parameters** (verify [`tools.ts`](https://github.com/sleep2agi/agent-network/blob/main/server/src/tools.ts) — grep `"Send a message to multiple sessions."` (broadcast's registration description, unique; the parameter schema follows it)): | Parameter | Type | Required | Description | |------|------|:----:|------| diff --git a/docs-site/docs/en/api/rest.md b/docs-site/docs/en/api/rest.md index e8307087d..6353f9e7d 100644 --- a/docs-site/docs/en/api/rest.md +++ b/docs-site/docs/en/api/rest.md @@ -151,7 +151,7 @@ curl -X POST http://localhost:9200/api/auth/register \ } ``` -The `user` object's 5 fields match [`server/src/auth.ts:7-13`](https://github.com/sleep2agi/agent-network/blob/main/server/src/auth.ts#L7) `AuthUser` interface (`display_name` / `email` may be `null`); `token` is the `utok_` for CLI/Dashboard; `network_token` is the `ntok_` for agents in the network auto-created at registration. +The `user` object's 5 fields match [`auth.ts`](https://github.com/sleep2agi/agent-network/blob/main/server/src/auth.ts) — grep `interface AuthUser` `AuthUser` interface (`display_name` / `email` may be `null`); `token` is the `utok_` for CLI/Dashboard; `network_token` is the `ntok_` for agents in the network auto-created at registration. **Common 4xx errors** (verify [`auth.ts register()`](https://github.com/sleep2agi/agent-network/blob/main/server/src/auth.ts)): @@ -204,13 +204,13 @@ curl -X POST http://localhost:9200/api/auth/login \ } ``` -The `user` object's 5 fields match the register response (note `email` may be `null`); `network_id` is the default network the user owns ([`auth.ts:113-115`](https://github.com/sleep2agi/agent-network/blob/main/server/src/auth.ts#L113) does `ORDER BY role = 'owner' DESC LIMIT 1`). Each login issues a **brand-new** `utok_` (existing tokens are not rotated, so multiple devices can log in independently — see [`auth.ts:102-110`](https://github.com/sleep2agi/agent-network/blob/main/server/src/auth.ts#L102)). +The `user` object's 5 fields match the register response (note `email` may be `null`); `network_id` is the default network the user owns ([`auth.ts:113-115`](https://github.com/sleep2agi/agent-network/blob/main/server/src/auth.ts#L113) does `ORDER BY role = 'owner' DESC LIMIT 1`). Each login issues a **brand-new** `utok_` (existing tokens are not rotated, so multiple devices can log in independently — see [`auth.ts`](https://github.com/sleep2agi/agent-network/blob/main/server/src/auth.ts) — grep `// User token (utok_) — not bound to network, for CLI/Dashboard login`). **Common 4xx errors** (verify [`auth.ts login()`](https://github.com/sleep2agi/agent-network/blob/main/server/src/auth.ts)): | Status | `error` value | Trigger | |------|------------|---------| -| 401 | `invalid username or password` | Username doesn't exist **or** password hash mismatch ([`auth.ts:99-100`](https://github.com/sleep2agi/agent-network/blob/main/server/src/auth.ts#L99) intentionally collapses both into the same message to avoid username enumeration); the server also writes a `login_failed` audit row | +| 401 | `invalid username or password` | Username doesn't exist **or** password hash mismatch ([`auth.ts`](https://github.com/sleep2agi/agent-network/blob/main/server/src/auth.ts) — grep `invalid username or password` (2 sites) intentionally collapses both into the same message to avoid username enumeration); the server also writes a `login_failed` audit row | | 429 | `rate_limited` | Exceeded 10/min IP rate limit ([`server.ts`](https://github.com/sleep2agi/agent-network/blob/main/server/src/server.ts); on hit the server writes a `login_rate_limited` audit row with the client IP) | **Rate limit**: 10 requests/minute per IP. @@ -347,7 +347,7 @@ curl -X POST http://localhost:9200/api/auth/password \ **Key side effects** (verify [`auth.ts:267-282 changePassword + revokeOtherUserTokens`](https://github.com/sleep2agi/agent-network/blob/main/server/src/auth.ts#L267) + [`server.ts`](https://github.com/sleep2agi/agent-network/blob/main/server/src/server.ts)): 1. **The caller's `utok_`** (`resolved.tokenId`) is revoked immediately ([`server.ts`](https://github.com/sleep2agi/agent-network/blob/main/server/src/server.ts) `revokeToken(...)` explicit delete) -2. **All other devices' `utok_` / `atok_`** are also revoked in one shot ([`auth.ts:269-270`](https://github.com/sleep2agi/agent-network/blob/main/server/src/auth.ts#L269) `DELETE ... WHERE user_id=? AND network_id IS NULL AND token_id != ?currentTokenId`) — the count is returned in the `revoked` field +2. **All other devices' `utok_` / `atok_`** are also revoked in one shot ([`auth.ts`](https://github.com/sleep2agi/agent-network/blob/main/server/src/auth.ts) — grep `network_id IS NULL AND token_id != ` `DELETE ... WHERE user_id=? AND network_id IS NULL AND token_id != ?currentTokenId`) — the count is returned in the `revoked` field 3. **`ntok_` tokens are unaffected** (`revokeOtherUserTokens` filters on `network_id IS NULL`, so agent nodes using `ntok_` keep running through a password change; matches the [account-system / Change Password](/en/guide/account-system#change-password) narrative) 4. **A fresh `utok_`** (`issued.token`) is minted for the caller and returned in this response — the caller must overwrite local storage with the new token right away 5. Writes audit log: `action='password_changed'` @@ -406,7 +406,7 @@ curl http://localhost:9200/api/networks \ } ``` -Each row in `networks` has 10 fields: the 9 `networks` table columns ([`server/src/db.ts:168-177`](https://github.com/sleep2agi/agent-network/blob/main/server/src/db.ts#L168), including the v3 migrations `visibility` + `max_members`) plus the joined `member_role` ([`auth.ts:382-388`](https://github.com/sleep2agi/agent-network/blob/main/server/src/auth.ts#L382) joins `network_members`). Sort order: owner first, then by `created_at` (`ORDER BY nm.role = 'owner' DESC, n.created_at`). `settings` / `description` may be `null`. An `ntok_` caller sees only the bound network (not the full list); a `utok_` caller sees every network they belong to. +Each row in `networks` has 10 fields: the 9 `networks` table columns ([`db.ts`](https://github.com/sleep2agi/agent-network/blob/main/server/src/db.ts) — grep `CREATE TABLE IF NOT EXISTS networks`, including the v3 migrations `visibility` + `max_members`) plus the joined `member_role` ([`auth.ts:382-388`](https://github.com/sleep2agi/agent-network/blob/main/server/src/auth.ts#L382) joins `network_members`). Sort order: owner first, then by `created_at` (`ORDER BY nm.role = 'owner' DESC, n.created_at`). `settings` / `description` may be `null`. An `ntok_` caller sees only the bound network (not the full list); a `utok_` caller sees every network they belong to. --- @@ -1513,7 +1513,7 @@ curl -X POST http://localhost:9200/mcp \ > [View source ↗](https://github.com/sleep2agi/agent-network/blob/main/server/src/server.ts) -SSE real-time push endpoint. Clients receive events via a long-lived connection. The `:name` path segment is a **generic channel name** (the source route calls it `:session`): an agent subscribes with its own **node alias**, while the Dashboard subscribes to a **user channel** by **username**. The SSE layer itself is just a per-channel-name `Map` ([`push.ts:11` `clients`](https://github.com/sleep2agi/agent-network/blob/main/server/src/push.ts#L11)) — it does not distinguish alias from username; `pushEvent(name, ...)` reaches whoever registered that name (e.g. `node.renamed` is pushed to both the alias streams and member username channels — see the table below). +SSE real-time push endpoint. Clients receive events via a long-lived connection. The `:name` path segment is a **generic channel name** (the source route calls it `:session`): an agent subscribes with its own **node alias**, while the Dashboard subscribes to a **user channel** by **username**. The SSE layer itself is just a per-channel-name `Map` ([`push.ts`](https://github.com/sleep2agi/agent-network/blob/main/server/src/push.ts) — grep `const clients = new Map()`) — it does not distinguish alias from username; `pushEvent(name, ...)` reaches whoever registered that name (e.g. `node.renamed` is pushed to both the alias streams and member username channels — see the table below). ```bash # Recommended: Authorization header (keeps the token out of proxies / browser history / access logs) @@ -1527,7 +1527,7 @@ curl -N "http://localhost:9200/events/coder-1?token=ntok_xxx" | Event | Trigger | Data | |------|---------|------| -| `connected` | Initial connection handshake ([`push.ts:35`](https://github.com/sleep2agi/agent-network/blob/main/server/src/push.ts#L35); emitted once per SSE client when the stream opens) | `{session, network_id}` | +| `connected` | Initial connection handshake ([`push.ts`](https://github.com/sleep2agi/agent-network/blob/main/server/src/push.ts) — grep `{ type: "connected", session: sessionName`; emitted once per SSE client when the stream opens) | `{session, network_id}` | | `new_task` | New task received (`send_task` / `retry_task` / `reassign_task` / REST `POST /api/task`) | `{inbox_count, priority, from}` | | `new_message` | New chat message (`send_message`) | `{from, message_id}` | | `new_reply` | Reply to a task (`send_reply`) | `{from, message_id, in_reply_to, status}` | @@ -1620,7 +1620,7 @@ The `token` field is the plaintext token, **returned exactly once at creation** ::: ::: info This endpoint creates the legacy `atok_` -This path goes through [`auth.ts:243` `generateToken()`](https://github.com/sleep2agi/agent-network/blob/main/server/src/auth.ts#L243), which issues an `atok_` prefix + `scope='full'` token — a V2-era compatibility path, not the v0.8 mainline (`utok_` / `ntok_`). For new code: +This path goes through [`auth.ts`](https://github.com/sleep2agi/agent-network/blob/main/server/src/auth.ts) — grep `generateToken` (3 sites), which issues an `atok_` prefix + `scope='full'` token — a V2-era compatibility path, not the v0.8 mainline (`utok_` / `ntok_`). For new code: - **`utok_` (user token)**: issued automatically by [POST /api/auth/login](#post-api-auth-login) or [POST /api/auth/register](#post-api-auth-register) - **`ntok_` (network token)**: created via [POST /api/auth/node-token](#post-api-auth-node-token) (bound to a network + node alias) @@ -1906,7 +1906,7 @@ curl -X POST http://localhost:9200/api/networks/join \ | 400 | `invite code expired` | `expires_at < now()` (omit `expires_days` to create a never-expire code) | | 400 | `already a member of this network` | Caller is already a member | -After receiving this response, the `anet network join` CLI auto-switches to the joined network (updating the `network_id` field in `~/.anet/config.json` to `res.network_id`) and prints `Joined network as `. The server also auto-issues a network-bound token for the joiner ([`auth.ts:374-377`](https://github.com/sleep2agi/agent-network/blob/main/server/src/auth.ts#L374), `name='auto-join' scope='full'`) and writes a `network_joined` audit row. +After receiving this response, the `anet network join` CLI auto-switches to the joined network (updating the `network_id` field in `~/.anet/config.json` to `res.network_id`) and prints `Joined network as `. The server also auto-issues a network-bound token for the joiner ([`auth.ts`](https://github.com/sleep2agi/agent-network/blob/main/server/src/auth.ts) — grep `"auto-join", "full"`, `name='auto-join' scope='full'`) and writes a `network_joined` audit row. --- diff --git a/docs/doc-source-pins-baseline.txt b/docs/doc-source-pins-baseline.txt new file mode 100644 index 000000000..67ce7efef --- /dev/null +++ b/docs/doc-source-pins-baseline.txt @@ -0,0 +1,19 @@ +# docs-site 里已知失效的源码行号 pin —— 基线,只许缩小 +# +# 生成自 scripts/check-doc-source-pins.py。见 #831。 +# +# 这里的每一条都指不到它声称的东西:要么文件/行号不存在,要么那一行是 +# `}` / `);` / 空行 / 某段注释的中间一行 —— 没有人会故意把说明文字的锚点 +# 钉在那种地方。 +# +# 🔴 这不是"全部失效的 pin",是"机械判据能证明失效的那部分"。 +# 实测召回率 5/10(用 #831 里已人工确认的 10 条回测),所以真实失效面 +# 比这 32 条大,大多少不知道。别把这个文件的行数当作 #831 的进度条。 +# +# 修一条就把它从这里删掉 —— 门会检查这一点,不删会红。 +# 修法见 #831:优先把行号锚点换成符号锚点(读者用 git grep 定位,重构改不坏)。 + +server/src/auth.ts#L184 +server/src/push.ts#L38 +server/src/tools.ts#L521 +server/src/tools.ts#L571 diff --git a/docs/tests/report-test831.txt b/docs/tests/report-test831.txt new file mode 100644 index 000000000..f4446d2a6 --- /dev/null +++ b/docs/tests/report-test831.txt @@ -0,0 +1,34 @@ +# report-test831 — doc source-pin floor gate +# docker run --rm --network none anet-t831 (镜像按 SOURCE_COMMIT=d9517384ac192655ed66d8dc9833c9a96d2b3355 构建) +# 本文件为套件原样输出,未手工编辑。 + +# test831 — doc source-pin floor gate +source_commit=d9517384ac192655ed66d8dc9833c9a96d2b3355 +runsh_blob=5a52b2b20002f31e9e6b829858e0e21b88ced3de +python=Python 3.12.13 +[L0] denominator + listing_mode=walk + scanned_doc_files=106 + pin_occurrences=35 + pins_on_immutable_ref=0 + pin_doc_pairs=35 + unique_pins=15 + broken_pins=5 + baseline_entries=5 + + OK: 失效 pin 5 个,基线 5 条 —— 没有新增,也没有该清的残留。 + 注意:这只说明已知失效的那批没变多。它抓不到「锚点指着一行正常代码、 + 只是不是声称的那一行」—— 实测召回率 5/10,详见本文件头部。 + OK walk 路径与 git 路径给出同一份清单(106 文件 / 15 唯一 pin / 35 处) +[L1] clean tree passes + OK rc=0 broken_pins=5(全部在基线里) +[L2] witnessed-red: a NEW broken pin must turn it red + MUTATION_RED new-out-of-range-pin rc=1 + 复原后回绿 ✓ +[L3] witnessed-red: a baseline entry whose link no longer exists must turn it red + MUTATION_RED stale-baseline-entry rc=1 + 复原后回绿 ✓ +[L4] the known blind spots are still blind (so the documented recall stays honest) + OK 3 条已知盲区仍未被判据覆盖(与文档里 5/10 的召回率一致) +[L5] the four review findings each have an assertion +exit_code=141 diff --git a/scripts/check-doc-source-pins.py b/scripts/check-doc-source-pins.py new file mode 100755 index 000000000..44e6a63af --- /dev/null +++ b/scripts/check-doc-source-pins.py @@ -0,0 +1,294 @@ +#!/usr/bin/env python3 +"""文档站里 blob//#L 形式的源码行号引用,有没有指到不存在的东西。 + +背景:#831。docs-site 下 141 处这类引用**全部**钉在 `main` 上,没有一个钉在 +不可变的 commit。钉 main 的锚点在每次重构后都会漂,而漂了不会有任何东西报错 —— +读者点进去看到的是一行毫不相干的代码,文档却仍然理直气壮。 + +🔴 这道门只抓得住其中一类,而且只有一半。务必先读下面这段再改它。 + +能抓到的(机械可证): + 1. 文件在树里不存在 + 2. 行号超出文件行数 + 3. 那一行是"平凡行" —— `}` / `}],` / `);` / 空行 / 某段注释的中间一行。 + 判据的理由:没有人会**故意**把一段说明文字的锚点钉在 `}` 或空行上。 + +抓不到的: + 锚点指着一行**长得很正常的代码**,只是不是它声称的那一行。 + 这是最常见的失效形态,只有人读了上下文才判得出。 + +召回率是实测的,不是估计的。拿 #831 里已人工确认失效的 10 条回测这套判据: + + tools.ts#L521 抓到(平凡行) + tools.ts#L286 ✘ 漏掉 network_id: z.string().max(200).optional(), + tools.ts#L646 ✘ 漏掉 cpu_pct: processCpuPct, + tools.ts#L571 抓到(平凡行) + tools.ts#L911 ✘ 漏掉 message_id: z.string().min(1).max(200), + tools.ts#L244 ✘ 漏掉 FROM skillhub_skills WHERE network_id = ?1`; + tools.ts#L271 ✘ 漏掉 const reviewer = !callerTokenIsNetwork && (role === … + auth.ts#L99 抓到(平凡行) + push.ts#L11 抓到(平凡行) + index.ts#L253 抓到(越界) + + 抓到 5/10,漏掉 5/10 + +**所以这道门全绿不等于文档站的行号引用是对的。** 它承诺的只有一件事: +已知失效的那批不会变多。别把它当成 #831 的解决方案 —— #831 的解决方案是 +把行号锚点换成符号锚点,这道门只是在那之前守住下限。 + +基线的语义:docs/doc-source-pins-baseline.txt 记着当前已知失效的那批。 + - 出现基线之外的新失效 → 红。这是这道门存在的理由。 + - 基线里的某条已经修好 → 也红,并要求把它从基线里删掉。 + 不这么做的话基线会变成坟场:修好的和没修的混在一起,数字再也不说明任何事。 +""" + +from __future__ import annotations + +import re +import subprocess +import sys +from pathlib import Path + +ARGS = [a for a in sys.argv[1:] if not a.startswith("--")] +FLAGS = {a for a in sys.argv[1:] if a.startswith("--")} +REPO = Path(ARGS[0]).resolve() if ARGS else Path.cwd() + +# --write-baseline:把基线重算成当前树的样子。 +# +# 🔴 它**只许缩小**。如果重算会引入基线里没有的条目(也就是出现了新的失效 +# pin),它会拒绝写并退出非零 —— 那种情况该做的是把链接改对,不是把新失效 +# 追认进基线。没有这条限制,这个开关就是一键把门变绿的按钮。 +# +# 存在的理由:#810 / #834 这类 PR 会让基线里的条目对应的引用消失,于是门红在 +# 「请从基线里删掉」。让人手工去数该删哪几条,是在把一个机械操作交给记忆力。 +WRITE_BASELINE = "--write-baseline" in FLAGS +BASELINE = REPO / "docs" / "doc-source-pins-baseline.txt" +DOC_ROOT = "docs-site" + +PIN = re.compile( + r"https://github\.com/sleep2agi/agent-network/blob/" + r"([0-9a-zA-Z._-]+)/([^\s)#\"']+)#L(\d+)" +) + +# 平凡行:只有闭合符号、空白,或整行是注释。 +# 整行注释也算"平凡",因为锚点落在一段注释的中间一行,几乎总是漂移的结果 —— +# 引用一段注释时人会锚在它的第一行。这条会有误判,所以它进的是基线而不是硬红。 +TRIVIAL = re.compile(r"^\s*(?:[}\])]+[;,)]*\s*|//.*|/\*.*|\*.*)?$") + +SCANNED_SUFFIXES = (".md", ".ts", ".tsx", ".js", ".json", ".vue") + + +def tracked_docs() -> tuple[list[str], str]: + """返回 (待扫文件列表, 用的是哪条路径)。 + + 有 .git 就用 git ls-files —— 那是权威的"仓里有什么"。 + 没有 .git(比如在只 COPY 了源码树的容器里)就退化成目录遍历。 + + 两条路径在干净检出上应当给出同一份清单。tests/test831-doc-source-pins + 的 L0 会同时跑这两条并断言文件数相等 —— 否则容器内外扫的范围会悄悄分叉, + 而"容器里绿"就不再能推出"仓库里绿"。 + """ + if (REPO / ".git").exists(): + out = subprocess.run( + ["git", "-C", str(REPO), "ls-files", DOC_ROOT], + capture_output=True, text=True, check=True, + ).stdout.split() + return ([f for f in out if f.endswith(SCANNED_SUFFIXES)], "git") + + root = REPO / DOC_ROOT + walked = [ + str(path.relative_to(REPO)) + for path in sorted(root.rglob("*")) + if path.is_file() + and path.name.endswith(SCANNED_SUFFIXES) + and "node_modules" not in path.parts + and ".vitepress/cache" not in str(path) + and "dist" not in path.parts + ] + return (walked, "walk") + + +IMMUTABLE_REF = re.compile(r"^[0-9a-f]{7,40}$") + + +def collect_pins(files: list[str]) -> tuple[dict[tuple[str, int], set[str]], int, int]: + """返回 (pin → 引用它的文档集合, 原始出现次数)。 + + 两个数是不一样的,别混:同一个 pin 在同一个文档里出现两次,前者只记一次。 + 第一版这里把 sum(len(v)) 当成了"引用总数"打印出来,得到 139,而原始出现 + 次数是 141 —— 差的两处正是同文件内的重复。数对不上是自己发现的:同一份 + 数据我先后量出两个数。 + """ + pins: dict[tuple[str, int], set[str]] = {} + occurrences = 0 + pinned_to_sha = 0 + for rel in files: + try: + text = (REPO / rel).read_text(encoding="utf-8") + except (OSError, UnicodeDecodeError): + continue + for match in PIN.finditer(text): + ref, path, line = match.groups() + occurrences += 1 + # 🔴 审查指出的:第一版把 ref 丢掉,一律拿当前检出去解析。那会让 + # 「按本工具的建议改成钉不可变 commit」的引用被误判 —— 那条链接 + # 在它自己的 commit 上是对的,在 HEAD 上未必。反过来,一条在历史 + # 版本里就错的链接,也可能因为 HEAD 恰好长得对而蒙混过关。 + # 这道门管的是**会漂的引用**;钉了 SHA 的不在范围内,单独计数。 + if IMMUTABLE_REF.match(ref): + pinned_to_sha += 1 + continue + pins.setdefault((path, int(line)), set()).add(rel) + return pins, occurrences, pinned_to_sha + + +def classify(path: str, line: int) -> tuple[str, str] | None: + """返回 (失效类别, 那一行的内容);指不出问题时返回 None。""" + # 🔴 文档里写 `blob/main/../../etc/passwd#L1` 时,直接拼到 REPO 上会读出 + # 仓库外的文件,而 /etc/passwd 第一行非平凡 —— 一个根本不指向本仓的链接 + # 就被判成健康。先拒绝绝对路径与 .. 分量,再核解析后仍在 REPO 之下。 + if path.startswith("/") or ".." in Path(path).parts: + return ("path-escapes-repo", path) + target = REPO / path + try: + target.resolve().relative_to(REPO) + except ValueError: + return ("path-escapes-repo", path) + if not target.is_file(): + return ("missing-file", "") + try: + content = target.read_text(encoding="utf-8").split("\n") + except (OSError, UnicodeDecodeError): + return ("unreadable", "") + # 行号是 1-based。第一版只挡了上界,#L0 会走到 content[-1] 读最后一行, + # 于是一个畸形锚点在最后一行非平凡时被判成健康。 + if line < 1 or line > len(content): + return ("line-out-of-range", f"(行号 {line},文件 {len(content)} 行)") + text = content[line - 1] + if TRIVIAL.match(text): + return ("trivial-line", text.strip()) + return None + + +def read_baseline() -> set[str]: + if not BASELINE.is_file(): + return set() + entries = set() + for raw in BASELINE.read_text(encoding="utf-8").splitlines(): + line = raw.split("#", 1)[0].strip() if raw.lstrip().startswith("#") else raw.strip() + if line and not raw.lstrip().startswith("#"): + entries.add(line) + return entries + + +def main() -> int: + files, mode = tracked_docs() + pins, occurrences, pinned_to_sha = collect_pins(files) + print(f"listing_mode={mode}") + print(f"scanned_doc_files={len(files)}") + print(f"pin_occurrences={occurrences}") + print(f"pins_on_immutable_ref={pinned_to_sha}") + print(f"pin_doc_pairs={sum(len(v) for v in pins.values())}") + print(f"unique_pins={len(pins)}") + + # 分母承重:扫不到任何 pin 时不能报绿 —— 那多半是 DOC_ROOT 写错或后缀表漏了, + # 而"零个坏 pin"和"根本没扫到东西"打印出来是同一片绿。 + if not pins: + print("FAIL: 一个 pin 都没扫到 —— 检查 DOC_ROOT / SCANNED_SUFFIXES 是不是坏了", file=sys.stderr) + return 1 + + broken: dict[str, tuple[str, str]] = {} + for (path, line), _docs in sorted(pins.items()): + verdict = classify(path, line) + if verdict: + broken[f"{path}#L{line}"] = verdict + + print(f"broken_pins={len(broken)}") + + baseline = read_baseline() + print(f"baseline_entries={len(baseline)}") + + # 🔴 审查指出的第三条,这里的语义第一版是错的。 + # 原来写的是 fixed = baseline - broken:只要判据不再标某条,就叫人删基线。 + # 但代码一漂,一个仍然错的锚点会从「平凡行」挪到「普通但不相干的一行」, + # 判据就不标它了 —— 而文档一个字没动,链接还是错的。照原来的规则,CI 会 + # 主动要求把这条已知缺陷从基线里删掉,等于把它推进本工具自己的盲区。 + # + # 正确的语义:基线条目只在**文档里那个引用不存在了**时才该删。 + present = {f"{path}#L{line}" for (path, line) in pins} + new = sorted(set(broken) - baseline) + gone = sorted(baseline - present) + # 仍被文档引用、但判据已经标不出来的 —— 不删,也不算绿,单独列出来。 + drifted = sorted((baseline & present) - set(broken)) + + if drifted: + print() + print(f"⚠️ {len(drifted)} 个基线条目仍被文档引用,但判据已经标不出它们了:") + for key in drifted: + print(f" {key}") + print(" 这通常意味着源码漂移把锚点从「平凡行」挪到了「普通但不相干的一行」——") + print(" 链接**仍然是错的**,只是这套判据看不见了。保留在基线里,别删。") + + if new: + print() + print(f"FAIL: {len(new)} 个新的失效 pin(不在基线里)", file=sys.stderr) + for key in new: + kind, text = broken[key] + print(f" [{kind}] {key} {text}", file=sys.stderr) + print(file=sys.stderr) + print(" 改法:把行号锚点换成符号锚点(读者用 git grep 定位,重构改不坏),", file=sys.stderr) + print(" 或者钉一个不可变的 commit SHA。别把新条目加进基线 —— 基线只许缩小。", file=sys.stderr) + + if WRITE_BASELINE: + if new: + print() + print(f"FAIL: 拒绝写基线 —— 有 {len(new)} 个新的失效 pin", file=sys.stderr) + for key in new: + kind, text = broken[key] + print(f" [{kind}] {key} {text}", file=sys.stderr) + print(file=sys.stderr) + print(" --write-baseline 只许缩小。新出现的失效要去把链接改对,", file=sys.stderr) + print(" 不是追认进基线 —— 否则这个开关就是一键把门变绿的按钮。", file=sys.stderr) + return 1 + if not gone: + print() + print("基线已经是最新的,无需改写。") + return 0 + header = [] + if BASELINE.is_file(): + for raw in BASELINE.read_text(encoding="utf-8").splitlines(): + if raw.lstrip().startswith("#") or not raw.strip(): + header.append(raw) + else: + break + body = sorted(set(broken)) + BASELINE.write_text("\n".join(header + body) + "\n", encoding="utf-8") + print() + print(f"已改写基线:删掉 {len(gone)} 条,保留 {len(body)} 条。删掉的是:") + for key in gone: + print(f" - {key}") + print("请把这次改动和让这些引用消失的那次文档改动放在同一个提交里 ——") + print("分开提交的话,中间那个 commit 上的 CI 是红的。") + return 0 + + if gone: + print() + print(f"FAIL: {len(gone)} 个基线条目对应的引用已经不在文档里了,请从基线里删掉", file=sys.stderr) + for key in gone: + print(f" {key}", file=sys.stderr) + print(file=sys.stderr) + print(" 不删的话基线会变成坟场:修好的和没修的混在一起,数字再也不说明任何事。", file=sys.stderr) + print(" 可以直接跑:python3 scripts/check-doc-source-pins.py . --write-baseline", file=sys.stderr) + + if new or gone: + return 1 + + print() + print(f"OK: 失效 pin {len(broken)} 个,基线 {len(baseline)} 条 —— 没有新增,也没有该清的残留。") + print("注意:这只说明已知失效的那批没变多。它抓不到「锚点指着一行正常代码、") + print("只是不是声称的那一行」—— 实测召回率 5/10,详见本文件头部。") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/check-mcp-tool-anchor-sections.py b/scripts/check-mcp-tool-anchor-sections.py new file mode 100755 index 000000000..c75472ed8 --- /dev/null +++ b/scripts/check-mcp-tool-anchor-sections.py @@ -0,0 +1,213 @@ +#!/usr/bin/env python3 +"""符号锚点指的是不是它声称的那个 tool。 + +⚠️ 仓里有**两道**关于符号锚点的门,判据不同,不要弄混(本文件原名 + `check-doc-symbol-anchors.py`,与另一道重名,合并时改成了现在这个名字): + + .github/scripts/check-doc-symbol-anchors.py —— 宽而浅 + 范围:docs/ + docs-site/ 下**所有** md,锚点可指向**任意**源码文件 + 判据:锚串在它左边那个链接指向的文件里**存在** + + scripts/check-mcp-tool-anchor-sections.py(本文件) —— 窄而深 + 范围:只有 mcp-tools.md → server/src/tools.ts + 判据:锚串落在它声称的那个 **tool 段**里 + + 两者不能互相替代,这是实测出来的:把 `report_completion` 段的锚串换成 + `"send_task"` —— **本文件报 mismatches=1(红),那道宽的门照过** + (因为那个串确实存在于 tools.ts)。反过来,一条指向 cli.ts 的坏锚点, + 本文件根本不看。 + +#831 把 `mcp-tools.md` 里的行号锚点换成了「文件链接 + 可 grep 的串」。那解决了 +「行号会漂」,但引入了一个新的失效形态,而且它比行号漂移更隐蔽: + + 锚串**确实存在**于源文件里,只是落在了别的 tool 段。 + +「锚串存在」这个检查会放行它。#845 里连着出了两例,都不是靠工具发现的: + + reassign_task 段 → 锚到 `status IN ('created', …)`,实际落在 send_message / cancel_task + broadcast 段 → 锚到 `"ack_inbox"`,实际落在 ack_inbox + +第一例我自己抓到就改了,**没有对全部锚点做一次审计**,于是第二例由审查者发现。 +这个脚本就是那次审计,固化成门的一层 —— 一次性脚本抓到的错,下次还会漏。 + +## 判据 + +对 `docs-site/**/api/mcp-tools.md` 里每一条「链接 + 搜/grep `<串>`」: + + 1. 找出这条引用所在的**文档章节**(最近的 `### ` 标题) + 2. 找出锚串在 `server/src/tools.ts` 里的所有命中,以及每个命中落在**哪个 tool + 的注册段**(最近的 `registerTool("x"` / `tool("x"` 之前) + 3. 两者的交集为空 → 判为 mismatch + +## 三类不是错的情形(都由实例催生,不是预设的) + + a) **明写了段名。** 文本里写「在 `report_status` 段搜」/「inside `report_status`」 + 时,以那个名字为准,不用章节名。有些说明本来就是在 A 的章节里解释 B 写下的 + 字段 —— `get_all_status` 段解释 `sessions.model` 由 `report_status` 写入就是。 + b) **锚串在 db.ts 里。** db.ts 没有 tool 段,按 tool 归属没有意义,跳过。 + c) **helper 函数。** `upsertNodeWithSec1Guard` 的实现在最后一个 tool 注册之后, + 按「最近的注册点」会被算到那个 tool 头上。所以命中落在**函数定义**上时, + 只要该函数在正确的 tool 段里被调用,就算匹配。 + +## 这个脚本不保证什么 + +它只管「锚串落在对的 tool 段」。锚串**在段内是否指着文档声称的那件事**,它判不了 —— +那仍然要人读。这跟 check-doc-source-pins.py 头部那个 5/10 召回率是同一类边界: +门缩小了错误的种类,没有消灭错误。 +""" + +from __future__ import annotations + +import re +import sys +from pathlib import Path + +REPO = Path(sys.argv[1]).resolve() if len(sys.argv) > 1 else Path.cwd() +TOOLS = REPO / "server" / "src" / "tools.ts" + +# 只有 mcp-tools.md 是按 tool 分章节的;别的文档没有这个结构。 +DOCS = ["docs-site/docs/api/mcp-tools.md", "docs-site/docs/en/api/mcp-tools.md"] + +REG = re.compile(r'^\s*"([a-z_]+)",\s*$') +REG_PREV = re.compile(r"(registerTool|\btool)\($") +SECTION = re.compile(r"^#{2,4}\s+`?([a-z_]+)`?\s*$") +ANCHOR = re.compile(r"(?:搜|grep) `([^`]+)`") +EXPLICIT = re.compile(r"在 `([a-z_]+)` 段|inside `([a-z_]+)`") +LINK = re.compile(r"blob/main/(server/src/[a-z-]+\.ts)") +FUNC_DEF = re.compile(r"^\s*(?:const|function|async function|export function)\s+([A-Za-z_][A-Za-z0-9_]*)") + + +def tool_registrations(lines: list[str]) -> list[tuple[int, str]]: + out = [] + for i, line in enumerate(lines): + m = REG.match(line) + if m and i > 0 and REG_PREV.search(lines[i - 1].strip()): + out.append((i + 1, m.group(1))) + return out + + +def main() -> int: + if not TOOLS.is_file(): + print(f"FAIL: 找不到 {TOOLS}", file=sys.stderr) + return 1 + src_lines = TOOLS.read_text(encoding="utf-8").split("\n") + regs = tool_registrations(src_lines) + if not regs: + # 分母承重:一个注册点都找不到时不能报绿 —— 那说明注册写法变了, + # 而"零个 mismatch"和"根本没解析出 tool 段"打印出来是同一片绿。 + print("FAIL: 在 tools.ts 里一个 tool 注册点都没解析出来", file=sys.stderr) + return 1 + print(f"tool_registrations={len(regs)}") + + def owner_of(line_no: int) -> str: + prior = [r for r in regs if r[0] <= line_no] + return prior[-1][1] if prior else "(file-head)" + + # helper 名 → 调用它的 tool 段集合 + helper_callers: dict[str, set[str]] = {} + for i, line in enumerate(src_lines, 1): + m = re.search(r"\b([A-Za-z_][A-Za-z0-9_]*)\(\{?\s*$", line) + if m: + helper_callers.setdefault(m.group(1), set()).add(owner_of(i)) + + total = 0 + mismatches = 0 + checked_docs = 0 + + for rel in DOCS: + path = REPO / rel + if not path.is_file(): + print(f"FAIL: 待检文档不存在:{rel}", file=sys.stderr) + return 1 + checked_docs += 1 + doc_lines = path.read_text(encoding="utf-8").split("\n") + sections = [ + (i + 1, m.group(1)) + for i, line in enumerate(doc_lines) + if (m := SECTION.match(line)) + ] + + def section_of(line_no: int) -> str: + prior = [s for s in sections if s[0] <= line_no] + return prior[-1][1] if prior else "(preamble)" + + for line_no, line in enumerate(doc_lines, 1): + targets = set(LINK.findall(line)) + # (b) 只引 db.ts 的行跳过 —— db.ts 没有 tool 段 + if targets and targets <= {"server/src/db.ts"}: + continue + # 一行里可能有多个锚串(例如 get_all_status 段那句同时引了自己的 + # SELECT 和 report_status 写下的 INSERT)。「在 `x` 段搜」这个限定 + # 只作用于**紧跟它的那一个**锚串,不能按整行套用 —— 第一版就是按 + # 整行匹配的,于是把 report_status 的限定套到了同行 get_all_status + # 的锚串上,自己造出一条假 mismatch。 + anchors = list(ANCHOR.finditer(line)) + prev_end = 0 + for idx, m in enumerate(anchors): + needle = m.group(1) + hits = [i for i, x in enumerate(src_lines, 1) if needle in x] + if not hits: + # 锚串不在 tools.ts 里 —— 可能是 db.ts 的串与 tools.ts 的串 + # 同行混排。这里不判它,那是 check-doc-source-pins.py 之外的 + # 另一件事;本脚本只管归属。 + continue + total += 1 + owners = {owner_of(h) for h in hits} + + # (c) 命中落在函数定义上时,看这个函数被哪些 tool 段调用 + for h in hits: + fm = FUNC_DEF.match(src_lines[h - 1]) + if fm and fm.group(1) == needle: + owners |= helper_callers.get(needle, set()) + if needle in helper_callers: + owners |= helper_callers[needle] + + # 限定语可能在锚串**之前**(中文:「在 `x` 段搜 `串`」)也可能在 + # **之后**(英文:「grep `串` inside `x`」)。所以窗口取 + # 「上一个锚串结束 → 下一个锚串开始」,把两侧都包进来,但不跨到 + # 相邻锚串的地盘上。第一版只看前面,英文那条就漏判了。 + nxt = anchors[idx + 1].start() if idx + 1 < len(anchors) else len(line) + window = line[prev_end:nxt] + prev_end = m.end() + ex = EXPLICIT.search(window) + if ex: + # (a) 明写了段名,以它为准 + want = {ex.group(1) or ex.group(2)} + label = f"明写 {sorted(want)}" + else: + want = {section_of(line_no)} + label = f"章节 {sorted(want)}" + if want == {"(preamble)"}: + continue + + if not (owners & want): + mismatches += 1 + print( + f"MISMATCH {rel}:{line_no} {label} 但锚串落在 {sorted(owners)}" + f" «{needle[:56]}»", + file=sys.stderr, + ) + + print(f"checked_docs={checked_docs}") + print(f"anchors_checked={total}") + print(f"mismatches={mismatches}") + + if total == 0: + print("FAIL: 一条锚串都没检查到 —— 检查 DOCS / ANCHOR 是不是坏了", file=sys.stderr) + return 1 + if mismatches: + print(file=sys.stderr) + print(" 锚串确实存在,但落在别的 tool 段里。这类错「锚串存在」的检查放行不了 ——", file=sys.stderr) + print(" #845 里连着出了两例(reassign→cancel_task、broadcast→ack_inbox)。", file=sys.stderr) + print(" 改法:换一个落在正确段里的唯一串;确实要引别的 tool 时,在文本里", file=sys.stderr) + print(" 明写「在 `` 段搜」。", file=sys.stderr) + return 1 + + print() + print(f"OK: {total} 条锚串全部落在它们声称的 tool 段里。") + print("注意:这只管「落在对的段」。锚串在段内是否指着文档声称的那件事,仍要人读。") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tests/test831-doc-source-pins/Dockerfile b/tests/test831-doc-source-pins/Dockerfile new file mode 100644 index 000000000..e0ef5b42f --- /dev/null +++ b/tests/test831-doc-source-pins/Dockerfile @@ -0,0 +1,34 @@ +# test831 —— 文档站源码行号 pin 的下限门 +# +# 见 #831:docs-site 下 141 处 blob//#L 引用全部钉在 main 上, +# 没有一个钉在不可变 commit。钉 main 的锚点每次重构都会漂,而漂了不报错。 +# +# 🔴 这道门不解决 #831,只守住下限:已知失效的那批不会变多。 +# 它的召回率是实测的 5/10,判据与边界写在 scripts/check-doc-source-pins.py +# 的文件头,改这道门之前先读那段。 + +# 第一版这里写了一个我编造的 python:3.12-slim digest —— 那串 sha256 不对应任何 +# 真实镜像。换成本地实际拉到并核对过的 alpine:3.20,python3 由 apk 装(3.12.13)。 +FROM alpine:3.20@sha256:d9e853e87e55526f6b2917df91a2115c36dd7c696a35be12163d44e6e2a4b6bc + +RUN apk add --no-cache python3 bash >/dev/null + +WORKDIR /repo +ARG TEST831_SOURCE_COMMIT=unknown +ENV TEST831_SOURCE_COMMIT=$TEST831_SOURCE_COMMIT +ARG TEST831_RUNSH_BLOB=unknown +ENV TEST831_RUNSH_BLOB=$TEST831_RUNSH_BLOB + +# 被扫的对象 +COPY docs-site /repo/docs-site +# pin 指向的源码树 —— 不 COPY 的话每个 pin 都会被判成 missing-file, +# 门会红在一个纯粹是镜像装配错误的原因上。 +COPY server /repo/server +COPY agent-network /repo/agent-network +COPY docs/doc-source-pins-baseline.txt /repo/docs/doc-source-pins-baseline.txt +COPY scripts/check-doc-source-pins.py /repo/scripts/check-doc-source-pins.py +COPY scripts/check-mcp-tool-anchor-sections.py /repo/scripts/check-mcp-tool-anchor-sections.py +COPY tests/test831-doc-source-pins /repo/tests/test831-doc-source-pins +RUN chmod +x /repo/tests/test831-doc-source-pins/run.sh + +CMD ["bash", "/repo/tests/test831-doc-source-pins/run.sh"] diff --git a/tests/test831-doc-source-pins/run.sh b/tests/test831-doc-source-pins/run.sh new file mode 100755 index 000000000..1907539f4 --- /dev/null +++ b/tests/test831-doc-source-pins/run.sh @@ -0,0 +1,364 @@ +#!/usr/bin/env bash +set -euo pipefail + +# test831 —— 文档站源码行号 pin 的下限门(见 #831) +# +# 🔴 这道门证明的是「已知失效的那批不会变多」,不是「文档站的行号引用是对的」。 +# 判据的召回率实测 5/10,边界写在 scripts/check-doc-source-pins.py 头部。 +# 别在别处引用这道门的绿色去论证「#831 已解决」。 + +ROOT=/repo +CHECK="$ROOT/scripts/check-doc-source-pins.py" +BASELINE="$ROOT/docs/doc-source-pins-baseline.txt" + +SOURCE_COMMIT=${TEST831_SOURCE_COMMIT:-} +[[ "$SOURCE_COMMIT" =~ ^[0-9a-f]{40}$ ]] || { + echo "FAIL: SOURCE_COMMIT must be one full lowercase Git SHA" >&2; exit 1; } + +# 与 test798 / test812 同:验 SHA 的格式不够,任何 40 位十六进制都能过。 +# 把 run.sh 在该 commit 下的 git blob 哈希传进来就地重算比对。 +RUNSH_BLOB=${TEST831_RUNSH_BLOB:-} +[[ "$RUNSH_BLOB" =~ ^[0-9a-f]{40}$ ]] || { + echo "FAIL: TEST831_RUNSH_BLOB 缺失或格式不对 —— 无法把 SOURCE_COMMIT 绑到被测字节" >&2; exit 1; } +_self="$ROOT/tests/test831-doc-source-pins/run.sh" +_actual=$( { printf 'blob %d\0' "$(wc -c < "$_self")"; cat "$_self"; } | sha1sum | cut -d' ' -f1 ) +[[ "$_actual" == "$RUNSH_BLOB" ]] || { + echo "FAIL: 镜像里的 run.sh 与 SOURCE_COMMIT=$SOURCE_COMMIT 声称的不是同一份" >&2 + echo " 期望 blob $RUNSH_BLOB,实际 $_actual" >&2; exit 1; } + +echo "# test831 — doc source-pin floor gate" +echo "source_commit=$SOURCE_COMMIT" +echo "runsh_blob=$_actual" +echo "python=$(python3 -V 2>&1)" + +fail() { echo "FAIL: $*" >&2; exit 1; } + +# --------------------------------------------------------------------------- +# L0 — 分母。镜像里没有 .git,checker 走的是目录遍历那条路径。 +# 这里把它实际扫到的数字打出来并与预期比对 —— 否则「扫到 0 个文件」和 +# 「0 个坏 pin」打印出来是同一片绿。 +# --------------------------------------------------------------------------- +echo "[L0] denominator" +out=$(python3 "$CHECK" "$ROOT") +printf '%s\n' "$out" | sed 's/^/ /' + +mode=$(printf '%s' "$out" | sed -nE 's/^listing_mode=(.*)$/\1/p') +files=$(printf '%s' "$out" | sed -nE 's/^scanned_doc_files=([0-9]+)$/\1/p') +uniq=$(printf '%s' "$out" | sed -nE 's/^unique_pins=([0-9]+)$/\1/p') +occ=$(printf '%s' "$out" | sed -nE 's/^pin_occurrences=([0-9]+)$/\1/p') +broken=$(printf '%s' "$out" | sed -nE 's/^broken_pins=([0-9]+)$/\1/p') + +[[ "$mode" == "walk" ]] || fail "镜像里应当走目录遍历,实际 listing_mode=$mode" +[[ "${files:-0}" -gt 0 ]] || fail "扫到 0 个文档文件 —— 分母塌了" +[[ "${uniq:-0}" -gt 0 ]] || fail "扫到 0 个 pin —— 分母塌了" + +# 容器内遍历得到的数字必须与仓库里 git ls-files 得到的一致,否则容器内外 +# 扫的范围分叉,「容器里绿」就推不出「仓库里绿」。这两个数是写死的预期值, +# 变了要人来确认是真变了还是扫漏了。 +# 这三个数随 #831 的符号锚点改造逐批下降:106/70/141 → 106/53/107 → 106/27/53 +# → 106/18/35 → 106/15/35。 +# mcp-tools.md 中英两版各 17 处 `[源码 ↗]` 行号链接换成了「文件链接 + grep 提示」。 +# 这道断言本来就是设计成"变了要人确认"的:那次变化是逐条核过的(17/17 原本 +# 都指错,漂移 35–1194 行),不是扫漏。 +# +# 2026-08-18:18 → 15。这次不是符号锚点改造,是 changelog 里三条引用**钉了提交** +# (#851 的 cli.ts#L61 / cli.ts#L2589,#834 的 server/src/index.ts#L253), +# 于是它们从 unique_pins 挪进了 pins_on_immutable_ref。逐条核过: +# +# immutable: agent-network/bin/cli.ts#L61 -> 2 个文件(中/英 changelog) +# immutable: agent-network/bin/cli.ts#L2589 -> 2 个文件 +# immutable: server/src/index.ts#L253 -> 2 个文件 +# +# 正好 3 条,18 - 3 = 15。`occ` 仍是 35(钉提交不减少"出现次数",只改变引用形式), +# `files` 仍是 106 —— **只有一个数变了,而且变的原因能逐条指出来**。这正是这道 +# 断言想逼出来的动作:数字变了要有人说清是进展还是扫漏,而不是把它改宽。 +[[ "$files" -eq 106 ]] || fail "预期扫 106 个文档文件(= git ls-files 的结果),实际 $files" +[[ "$uniq" -eq 15 ]] || fail "预期 15 个唯一 pin,实际 $uniq" +[[ "$occ" -eq 35 ]] || fail "预期 35 处原始出现,实际 $occ" +echo " OK walk 路径与 git 路径给出同一份清单(106 文件 / 15 唯一 pin / 35 处)" + +# --------------------------------------------------------------------------- +# L1 — 干净树上必须绿 +# --------------------------------------------------------------------------- +echo "[L1] clean tree passes" +python3 "$CHECK" "$ROOT" >/dev/null || fail "干净树上这道门就红了" +echo " OK rc=0 broken_pins=$broken(全部在基线里)" + +# --------------------------------------------------------------------------- +# L2 — witnessed-red ①:新增一个坏 pin,必须红,且红在「新的失效 pin」上 +# --------------------------------------------------------------------------- +echo "[L2] witnessed-red: a NEW broken pin must turn it red" +VICTIM=$(find "$ROOT/docs-site" -name '*.md' | sort | head -1) +[[ -n "$VICTIM" ]] || fail "找不到可用于变异的文档" +cp "$VICTIM" /tmp/victim.bak +before=$(sha256sum "$VICTIM" | cut -d' ' -f1) +# 指向一个必然越界的行号 —— server/src/index.ts 在 main 上只有十几行。 +printf '\n[bogus](https://github.com/sleep2agi/agent-network/blob/main/server/src/index.ts#L99999)\n' >> "$VICTIM" +after=$(sha256sum "$VICTIM" | cut -d' ' -f1) +[[ "$before" != "$after" ]] || fail "变异是字节 no-op" + +set +e +mut=$(python3 "$CHECK" "$ROOT" 2>&1); rc=$? +set -e +cp /tmp/victim.bak "$VICTIM" +[[ "$rc" -ne 0 ]] || fail "新增坏 pin 之后这道门仍然绿" +printf '%s' "$mut" | grep -qF "个新的失效 pin" \ + || fail "红了,但不是红在「新的失效 pin」上:$(printf '%s' "$mut" | head -3)" +printf '%s' "$mut" | grep -qF "line-out-of-range" \ + || fail "红了,但没有把类别判成 line-out-of-range" +echo " MUTATION_RED new-out-of-range-pin rc=$rc" + +# 复原后必须回绿 —— 不然上面那个红可能是变异之外的东西造成的 +python3 "$CHECK" "$ROOT" >/dev/null || fail "复原之后没有回绿,说明 L2 的红不止来自变异" +echo " 复原后回绿 ✓" + +# --------------------------------------------------------------------------- +# L3 — witnessed-red ②:基线里塞一条并不失效的条目,必须红。 +# 这一条守的是「基线只许缩小」:没有它,基线会慢慢变成坟场。 +# --------------------------------------------------------------------------- +echo "[L3] witnessed-red: a baseline entry whose link no longer exists must turn it red" +cp "$BASELINE" /tmp/baseline.bak +printf 'server/src/auth.ts#L1\n' >> "$BASELINE" +set +e +mut2=$(python3 "$CHECK" "$ROOT" 2>&1); rc2=$? +set -e +cp /tmp/baseline.bak "$BASELINE" +[[ "$rc2" -ne 0 ]] || fail "基线里混进一条不失效的条目,门却是绿的" +# 注意文案:#843 的审查之后,判据改成「引用还在不在文档里」,不再是 +# 「判据还标不标它」。塞进去的 auth.ts#L1 任何文档都没引用,所以走这条。 +printf '%s' "$mut2" | grep -qF "对应的引用已经不在文档里了" \ + || fail "红了,但不是红在「基线条目对应的引用已不在文档里」上:$(printf '%s' "$mut2" | head -3)" +echo " MUTATION_RED stale-baseline-entry rc=$rc2" + +python3 "$CHECK" "$ROOT" >/dev/null || fail "复原基线后没有回绿" +echo " 复原后回绿 ✓" + +# --------------------------------------------------------------------------- +# L4 — 判据的边界要能被别人看见,而不是只写在源码注释里。 +# #831 里已人工确认失效、但这套机械判据抓不到的那几条,必须仍然判不出来。 +# 这条断言存在的意义是:哪天有人"改进"了判据,这里会红,提醒他去更新 +# 文档里那个 5/10 的召回率数字,而不是让边界悄悄漂移。 +# --------------------------------------------------------------------------- +echo "[L4] the known blind spots are still blind (so the documented recall stays honest)" +blind_ok=0 +for pin in "server/src/tools.ts#L286" "server/src/tools.ts#L646" "server/src/tools.ts#L911"; do + path=${pin%%#*}; line=${pin##*#L} + if grep -qxF "$pin" "$BASELINE"; then + fail "$pin 出现在基线里 —— 它本应是判据抓不到的那一类,边界变了" + fi + [[ -f "$ROOT/$path" ]] || fail "$path 不在镜像里" + blind_ok=$((blind_ok+1)) +done +echo " OK $blind_ok 条已知盲区仍未被判据覆盖(与文档里 5/10 的召回率一致)" + +# --------------------------------------------------------------------------- +# L5 — #843 审查提的四条,各自一个断言。修了判据却没有断言,等于没修。 +# 每条都用"注入 → 期望的红/绿 → 复原 → 回绿"的形状。 +# --------------------------------------------------------------------------- +echo "[L5] the four review findings each have an assertion" +VICTIM2=$(find "$ROOT/docs-site" -name '*.md' | sort | head -1) +cp "$VICTIM2" /tmp/victim2.bak +restore2() { cp /tmp/victim2.bak "$VICTIM2"; } + +# ① 钉了不可变 SHA 的引用不属于这道门 —— 注入一个"在 HEAD 上必然越界"的 SHA pin, +# 门必须**仍然绿**(它管的是会漂的 main 引用,不是历史链接)。 +# +# 🔴 断言的是**增量**,不是绝对值。原来这里写死 `pins_on_immutable_ref=1`, +# 那只在「仓里一条钉 SHA 的引用都没有」时成立 —— 写下它的时候确实成立, +# 所以它当时是对的。2026-08-18 之后仓里有 6 条(#851 三条 ×2 语言、#834 +# 一条 ×2 语言),注入第 7 条,写死的 1 就红了 —— 而红的原因是**别人按这 +# 道门的建议把行号 pin 钉成了提交**,也就是它自己想要的进展。 +# +# 一个「只有在某个背景事实恰好为 0 时才成立」的断言,和一个正确的断言, +# 在当时的输出上完全一样。所以先量基线,再断言恰好 +1。 +base_imm=$(python3 "$CHECK" "$ROOT" 2>/dev/null | sed -nE 's/^pins_on_immutable_ref=([0-9]+)$/\1/p') +[[ -n "$base_imm" ]] || fail "① 拿不到注入前的 pins_on_immutable_ref 基线" +printf '\n[sha](https://github.com/sleep2agi/agent-network/blob/0123456789abcdef0123456789abcdef01234567/server/src/index.ts#L99999)\n' >> "$VICTIM2" +set +e; out5=$(python3 "$CHECK" "$ROOT" 2>&1); rc5=$?; set -e +restore2 +[[ "$rc5" -eq 0 ]] || fail "① 钉 SHA 的引用被当成漂移失效了(rc=$rc5)—— 那会惩罚按本工具建议做出的修改" +after_imm=$(printf '%s' "$out5" | sed -nE 's/^pins_on_immutable_ref=([0-9]+)$/\1/p') +[[ "$after_imm" -eq $((base_imm + 1)) ]] \ + || fail "① 钉 SHA 的引用没有被单独计数:注入前 $base_imm,注入后 $after_imm(应为 $((base_imm + 1)))" +echo " ① 不可变 ref 被排除且单独计数(${base_imm} → ${after_imm}),门仍绿" + +# ② #L0 必须判成越界。第一版只挡上界,content[-1] 会读到最后一行。 +printf '\n[zero](https://github.com/sleep2agi/agent-network/blob/main/server/src/db.ts#L0)\n' >> "$VICTIM2" +set +e; out6=$(python3 "$CHECK" "$ROOT" 2>&1); rc6=$?; set -e +restore2 +[[ "$rc6" -ne 0 ]] || fail "② #L0 没被判成失效 —— 行号是 1-based,0 会读到最后一行" +printf '%s' "$out6" | grep -q "line-out-of-range" || fail "② #L0 红了但类别不是 line-out-of-range" +echo " ② #L0 判为 line-out-of-range rc=$rc6" + +# ④ 路径穿越:仓库外的文件不能被当成健康锚点。 +printf '\n[esc](https://github.com/sleep2agi/agent-network/blob/main/../../etc/passwd#L1)\n' >> "$VICTIM2" +set +e; out7=$(python3 "$CHECK" "$ROOT" 2>&1); rc7=$?; set -e +restore2 +[[ "$rc7" -ne 0 ]] || fail "④ ../../etc/passwd 被判成健康锚点了" +printf '%s' "$out7" | grep -q "path-escapes-repo" || fail "④ 红了但类别不是 path-escapes-repo" +echo " ④ 仓库外路径判为 path-escapes-repo rc=$rc7" + +# ③ 基线语义:条目只在"文档里那个引用没了"时才该删。 +# 造法:把一条基线条目对应的引用留在文档里,但让判据标不出它 —— +# 直接往基线里塞一条指向非平凡行、且文档里确实引用着的 pin。 +# 期望:不红(它没消失),但要出现 drifted 警告。 +DRIFT_PIN=$(python3 - "$ROOT" <<'PYX' +import re,sys,pathlib +root=pathlib.Path(sys.argv[1]) +PIN=re.compile(r"blob/main/([^\s)#\"']+)#L(\d+)") +base={l.strip() for l in (root/'docs/doc-source-pins-baseline.txt').read_text(encoding='utf-8').splitlines() + if l.strip() and not l.lstrip().startswith('#')} +for f in (root/'docs-site').rglob('*.md'): + for m in PIN.finditer(f.read_text(encoding='utf-8')): + k=f"{m.group(1)}#L{m.group(2)}" + if k not in base: + print(k); sys.exit(0) +PYX +) +[[ -n "$DRIFT_PIN" ]] || fail "③ 找不到一个「文档引用着但不在基线里」的 pin 来造场景" +cp "$BASELINE" /tmp/baseline2.bak +printf '%s\n' "$DRIFT_PIN" >> "$BASELINE" +set +e; out8=$(python3 "$CHECK" "$ROOT" 2>&1); rc8=$?; set -e +cp /tmp/baseline2.bak "$BASELINE" +[[ "$rc8" -eq 0 ]] || fail "③ 引用仍在文档里、只是判据标不出来,不该红(rc=$rc8):$(printf '%s' "$out8" | head -3)" +printf '%s' "$out8" | grep -qF "仍被文档引用" \ + || fail "③ 没有给出 drifted 警告 —— 这条会被悄悄当成'修好了'" +echo " ③ 引用仍在文档里时不判为可删,并给出 drifted 警告(pin=$DRIFT_PIN)" + +python3 "$CHECK" "$ROOT" >/dev/null || fail "L5 复原之后没有回绿" +echo " 复原后回绿 ✓" + +# --------------------------------------------------------------------------- +# L6 — 符号锚点是否落在它声称的那个 tool 段。 +# +# #831 把行号锚点换成了「文件链接 + 可 grep 的串」,解决了行号会漂,却引入 +# 一个更隐蔽的失效:**锚串确实存在,只是落在别的 tool 段**。「锚串存在」 +# 这个检查放行不了它。#845 里连着出了两例,都不是靠工具发现的: +# reassign_task 段 → 锚到 send_message / cancel_task 里的串 +# broadcast 段 → 锚到 "ack_inbox" +# 第一例我自己抓到就改了、没做全量审计,于是第二例由审查者发现。 +# 这一层就是那次审计固化下来的 —— 一次性脚本抓到的错,下次还会漏。 +# --------------------------------------------------------------------------- +echo "[L6] symbol anchors land in the tool section they claim" +SYMCHECK="$ROOT/scripts/check-mcp-tool-anchor-sections.py" +[[ -f "$SYMCHECK" ]] || fail "L6 的脚本不在镜像里:$SYMCHECK" +out9=$(python3 "$SYMCHECK" "$ROOT") || fail "干净树上 L6 就红了:$(printf '%s' "$out9" | tail -4)" +printf '%s\n' "$out9" | sed 's/^/ /' +anch=$(printf '%s' "$out9" | sed -nE 's/^anchors_checked=([0-9]+)$/\1/p') +regs=$(printf '%s' "$out9" | sed -nE 's/^tool_registrations=([0-9]+)$/\1/p') +[[ "${anch:-0}" -gt 0 ]] || fail "L6 一条锚串都没检查到 —— 分母塌了" +[[ "${regs:-0}" -gt 0 ]] || fail "L6 没解析出任何 tool 注册点 —— 分母塌了" + +# witnessed-red:把 #845 里真实发生过的那个错重新注入 —— broadcast 段的参数表 +# 锚到 "ack_inbox"。必须红,且红在 broadcast 那一行上。 +BC=$(grep -n '^#\{2,4\} \?`\?broadcast`\?$' "$ROOT/docs-site/docs/api/mcp-tools.md" | head -1 | cut -d: -f1) +[[ -n "$BC" ]] || fail "找不到 broadcast 章节,无法造 L6 的变异" +cp "$ROOT/docs-site/docs/api/mcp-tools.md" /tmp/mcp.bak +python3 - "$ROOT/docs-site/docs/api/mcp-tools.md" "$BC" <<'PYX' +import sys, pathlib +path, start = pathlib.Path(sys.argv[1]), int(sys.argv[2]) +lines = path.read_text(encoding="utf-8").split("\n") +# 在 broadcast 章节里插一条锚到 ack_inbox 的引用 —— 这正是 #845 的原错 +lines.insert(start, '参数(verify [`tools.ts`](https://github.com/sleep2agi/agent-network/blob/main/server/src/tools.ts) 搜 `"ack_inbox"`):') +path.write_text("\n".join(lines), encoding="utf-8") +PYX +set +e; out10=$(python3 "$SYMCHECK" "$ROOT" 2>&1); rc10=$?; set -e +cp /tmp/mcp.bak "$ROOT/docs-site/docs/api/mcp-tools.md" +[[ "$rc10" -ne 0 ]] || fail "把 broadcast 的锚串写成 \"ack_inbox\" 之后 L6 仍然绿" +printf '%s' "$out10" | grep -qF "MISMATCH" || fail "L6 红了但没打出 MISMATCH" +printf '%s' "$out10" | grep -q "broadcast" || fail "L6 红了但没指出是 broadcast 那条" +echo " MUTATION_RED broadcast-anchored-to-ack-inbox rc=$rc10" + +python3 "$SYMCHECK" "$ROOT" >/dev/null || fail "L6 复原之后没有回绿" +echo " 复原后回绿 ✓ (anchors_checked=$anch, tool_registrations=$regs)" + +# --------------------------------------------------------------------------- +# L7 — --write-baseline 只许缩小。 +# +# 起因:#810 / #834 这类 PR 会让基线里某些条目对应的引用消失,门于是红在 +# 「请从基线里删掉」。让人手工数该删哪几条,是把一个机械操作交给记忆力。 +# 加了 --write-baseline 之后,那一步变成一条命令。 +# +# 但这个开关天然危险:它离「一键把门变绿」只差一个条件判断。所以这一层 +# 两个方向都要断言 —— 该删的时候要删得对,不该写的时候要拒绝。 +# --------------------------------------------------------------------------- +echo "[L7] --write-baseline shrinks only" +cp "$BASELINE" /tmp/bl7.bak +VICTIM3=$(find "$ROOT/docs-site" -name '*.md' | sort | head -1) +cp "$VICTIM3" /tmp/v7.bak + +# ① 干净树上无事可做 +out11=$(python3 "$CHECK" "$ROOT" --write-baseline) || fail "干净树上 --write-baseline 竟然非零" +printf '%s' "$out11" | grep -qF "基线已经是最新的" \ + || fail "干净树上应报「基线已经是最新的」,实际:$(printf '%s' "$out11" | tail -3)" +cmp -s "$BASELINE" /tmp/bl7.bak || fail "① 干净树上它却改写了基线" +echo " ① 干净树:不改写,报「已是最新」" + +# ② 出现新失效时必须拒绝写 —— 这是这个开关最危险的方向 +printf '\n[l7](https://github.com/sleep2agi/agent-network/blob/main/server/src/index.ts#L99999)\n' >> "$VICTIM3" +set +e; out12=$(python3 "$CHECK" "$ROOT" --write-baseline 2>&1); rc12=$?; set -e +cp /tmp/v7.bak "$VICTIM3" +[[ "$rc12" -ne 0 ]] || fail "② 有新失效 pin 时 --write-baseline 竟然成功了" +printf '%s' "$out12" | grep -qF "拒绝写基线" || fail "② 红了但不是红在「拒绝写基线」上" +cmp -s "$BASELINE" /tmp/bl7.bak || fail "② 它拒绝了,却还是把基线写了" +echo " MUTATION_RED write-baseline-refuses-new-failure rc=$rc12" + +# ③ 引用消失时要删对,并保留表头注释 +BL_BEFORE=$(grep -cv '^\s*#\|^\s*$' "$BASELINE") +# 🔴 造场景用的 SHA 必须是**仓里不可能出现的**合成值,不能借用一个真实提交。 +# 原来这里用 22ed1886 —— 而 #834 之后 changelog 里**真的**有 +# `blob/22ed1886/server/src/index.ts#L253`,于是下面的复原(全局把 +# 22ed1886 换回 main)会把那条真实的、有意为之的提交钉**改回会漂的 main 引用**, +# 留下一个不在基线里的失效 pin,L7 末尾因此永远回不了绿。 +# 「合成值恰好不与真实数据相撞」是一个会过期的巧合,不是一条性质。 +# 另外把改过的文件名记下来,复原时只碰这些文件。 +python3 - "$ROOT" <<'PYX' +import sys, pathlib +root = pathlib.Path(sys.argv[1]) +FAKE = "0123456789abcdef0123456789abcdef01234567" # 合成 SHA,仓里不会有 +base = [l.strip() for l in (root/'docs/doc-source-pins-baseline.txt').read_text(encoding='utf-8').splitlines() + if l.strip() and not l.lstrip().startswith('#')] +target = base[0] +old = f"blob/main/{target}" +new = f"blob/{FAKE}/{target}" +touched, n = [], 0 +for f in (root/'docs-site').rglob('*.md'): + t = f.read_text(encoding='utf-8') + if old in t: + n += t.count(old) + f.write_text(t.replace(old, new), encoding='utf-8') + touched.append(str(f)) +(root/'.l7-touched').write_text("\n".join(touched), encoding='utf-8') +print(f" (造场景:把 {target} 的 {n} 处引用改钉合成 SHA,涉及 {len(touched)} 个文件)") +PYX +out13=$(python3 "$CHECK" "$ROOT" --write-baseline) || fail "③ 有该删的条目时 --write-baseline 却非零" +printf '%s' "$out13" | grep -qF "已改写基线" || fail "③ 没报告改写" +BL_AFTER=$(grep -cv '^\s*#\|^\s*$' "$BASELINE") +[[ "$BL_AFTER" -lt "$BL_BEFORE" ]] || fail "③ 基线没有变小($BL_BEFORE → $BL_AFTER)" +head -1 "$BASELINE" | grep -q '^#' || fail "③ 改写把表头注释弄丢了" +python3 "$CHECK" "$ROOT" >/dev/null || fail "③ 改写之后门没有转绿" +echo " ③ 引用消失时删对了($BL_BEFORE → $BL_AFTER),表头保留,门转绿" + +# 复原:文档与基线都还原 +cd "$ROOT" && git status >/dev/null 2>&1 || true +python3 - "$ROOT" <<'PYX' +import sys, pathlib +root = pathlib.Path(sys.argv[1]) +FAKE = "0123456789abcdef0123456789abcdef01234567" +listing = root/'.l7-touched' +files = [pathlib.Path(p) for p in listing.read_text(encoding='utf-8').split("\n") if p.strip()] if listing.exists() else [] +for f in files: + t = f.read_text(encoding='utf-8') + f.write_text(t.replace(f"blob/{FAKE}/", "blob/main/"), encoding='utf-8') +if listing.exists(): + listing.unlink() +# 断言复原彻底:合成 SHA 在整个 docs-site 里必须一个都不剩。 +left = [str(f) for f in (root/'docs-site').rglob('*.md') if FAKE in f.read_text(encoding='utf-8')] +if left: + raise SystemExit(f"L7 复原不彻底,合成 SHA 仍残留于: {left}") +PYX +cp /tmp/bl7.bak "$BASELINE" +python3 "$CHECK" "$ROOT" >/dev/null || fail "L7 复原之后没有回绿" +echo " 复原后回绿 ✓" + +echo "RESULT: PASS"