Files
homeagent-sdk/tools/hmapdev/rawfiles.go
JianFeeeee 81a3efa4df feat(commondev): 主管式多仓库开发流水线 + sidecar 后端抽象(pi/zcode) (#3)
* feat(commondev): 主管式多仓库开发流水线 + sidecar 后端抽象(pi/zcode)

## 是什么

agent 统筹开发流程的插件:登记项目 → 开工程线(每条线一个隔离工作目录 +
一个 sidecar 子进程 + 一对通道)→ 投递任务 → 同会话续投 → 评审/合入。

38 个工具:project_*/branch_*/workdir_*/job_*/review_*/test_*/
endpoint_*/agent_*/pi_*/conflict_check。

## sidecar 后端抽象(本轮核心)

传输层(local/ssh)与后端(pi/zcode/自定义)**正交**,四个自由组合:

  driver.json 描述一个后端:script / cliPath / env / args / llmConfig
  三级查找:插件数据目录 → **随包 rawfile** → 内置 pi 描述
  占位符:{workdir} {dataDir} {pluginDir} {rawfile} {sessionId}

★ 内置 pi 回退**与默认后端解耦**(2026-10-17 修):原实现写
`if cli == defaultAgentCLI { return builtinPiDriver() }`,把"给谁兜底"
和"默认选谁"耦合到同一常量 —— 一旦有人改默认,pi 当场失去兜底,
显式 cli="pi" 开始报「驱动不存在」。现在判断字面 "pi"。

默认后端保持 **pi**(我们本地主用);交付方用 zcode 走两种局部开关:
 · 单线:commondev_branch_open(cli="zcode")
 · 按机器:commondev_endpoint_set(host=..., cli="zcode")

## zcode 接入(三条真机判据,全部要求真改工程文件)

 · TestZcodeFullPipeline(29-34s):随包 driver + 真实 ZCode CLI
 · TestZcodeOverSSH(39s):真 ssh + scp,远端 node 跑 zcode
 · TestZcodeOverSSHWithPerHostEndpoints(68s):两个 host 别名配不同
   cli_path,走端点表而不是全局 settings

★ 会话库必须隔离到工作区(sidecar.mjs 的 ensureWorkspaceSessionStore):
实测 ZCode 的会话库路径**硬编码 homedir()**,`ZCODE_DATA_BASE_DIR` 对它
完全无效(26 个会话全落全局库)。但 `<workdir>/.zcode/config.json` 的
`storage.sessionDbPath` **确实生效**。所以启动前写一份工作区配置,
一举解决:多线不共用历史 / 不污染全局 ~/.zcode / 删线不留孤儿会话。
只在不存在时写 —— 用户手工放的要尊重。

## 其他

 · rawfile:项目根 rawfile/ 默认全收,解到 <插件目录>/rawfile/,绝对路径拒绝
 · 凭证:命名引用 + ${ENV_VAR},独立 0600 文件,绝不进 config.db(0644)
 · secretshell:给不受信 agent 用的安全 shell(ackey + safeshell)
 · 冲突检查 / 评审判据 / 镜像 / 每条线独立 workdir(hash 命名,合并后删除)

判据 30+ 条,含 4 条「判据本身的判据」(防止判据假绿)。

* fix(sdk): 协议通道纪律 + hmapdev 模板升级 + 示例插件 stdout 污染

## 核心缺陷:插件往 stdout 打日志 = 污染 JSON-RPC 协议通道

子进程插件与内核用 stdout 传 NDJSON 帧,stderr 才是日志通道。
脚手架模板(及 4 个示例插件)用 `fmt.Printf` 打日志 ⇒ 内核收到非 JSON 文本:

    [proc] weather 收到非法 JSON 帧(21 字节): invalid character 'i'

**生产实测 1038 条**(huawei_smarthome 720 / weather 113 / rss 103 / calendar 102)。
后果不只是日志噪音:那一行日志被**丢弃**,插件看起来"没启动"。

修法:
- hmapdev 模板 `fmt.Printf` → `log.Printf`(9 处)
- 示例插件 4 个 14 处:ai_image(2) calendar(5) rss(4) weather(3)
  + 补 "log" import
- 内核侧(主仓)同步让报错可定位:内容摘要 + 可能原因 + 修法

判别法:`package main` = 子进程(协议通道,禁用 stdout 打日志);
普通库包 = 进程内插件(编译进内核,写真实终端,**不是**缺陷)——
`internal/plugins/cli` 的 `fmt.Println` 属后者。

## hmapdev

- `cmd_build.go` +136:平台产物精确匹配(`--target` 是**append** 语义,
  原脚本 `ls dist/*.hmap | head -1` 会按字典序抓到 amd64,
  **导致 arm64 包里装的是 x86-64 二进制**,且退出码 0、校验和通过)
- `cmd_init.go` +46:脚手架 stdio 纪律
- `templates/proc_main.go.tmpl`:补齐 mgrStop/mgrStart/mgrIsRuntimeStopped
  三态能力(插件管理器运行态停启)
- `templates/plg.json.tmpl` 删除(合并进 cmd_init)

## 判据

- `scaffold_stdout_test.go`(3 条):扫模板生成的代码不得写 stdout。
  ★ 判据自身先假红过一次:**模板的注释里就写了 `fmt.Printf`** ⇒
  必须 `stripTemplateComments()` 之后才判
- `example_stdout_test.go`(2 条):扫全部 20 个子进程示例插件,
  报错给**文件:行号**;变异咬住并给出准确行号
- `package_examples_test.go`(2 条):**执行真实打包脚本**
  (第一版测的是 hmapdev 而不是脚本 ⇒ 变异不咬)
- `cmd_build` / `cmd_init` 新判据

## 其他

- sdk/settings.go +15(三态能力透传)
- `docs/versions.md` +§示例插件协议通道纪律(含生产 1038 条实测表格)
- `skills/homeagent-plugin-dev/SKILL.md` +§协议通道纪律(正误代码对比)

* fix(examples): 恢复 go.mod 相对路径 + 移除 plg.json 的 sdk 固定(修 CI 红)

CI 上 `Examples (linux/amd64)` 与 `(darwin/arm64)` 双红,只有两个失败:

  ✗ commondev   构建失败
  ✗ editdoc     构建失败
  error: 项目声明 sdk=1.4.0,但本地 SDK 存储里没有这个版本

其余 20 个示例全部成功 —— 说明不是通用问题,而是这两个被改坏了。

## 根因(两个叠加)

**① go.mod 被本机绝对路径污染**

我在上一个提交里用 `git add -A`,把本机 `go build` **自动改写**过的
go.mod 一并提交了:

  require github.com/JianFeeeee/homeagentsdk v1.4.0
  replace github.com/JianFeeeee/homeagentsdk => /root/.homeagent/hmapdev/sdk/v1.4.0

基线(origin/main)是:

  require github.com/JianFeeeee/homeagentsdk v0.0.0
  replace github.com/JianFeeeee/homeagentsdk => ../../

本机那个目录真的存在 ⇒ 本地全绿;CI 没有 ⇒ 必红。

**② plg.json 声明了 `"sdk": "1.4.0"`**

一声明版本,hmapdev 就去**本地 SDK store** 找它,找不到直接拒绝构建。
全仓 20+ 示例**都不声明**,只有我改过的这两个声明了。
示例的正确依赖表达是 go.mod 的 `v0.0.0` + `replace ../../`。

## 修法

两个文件都恢复到与其余示例一致的写法。

## 判据(3 条,都是新加的)

 · `TestExampleGoModHasNoAbsoluteReplace`:任何示例 go.mod 不得含
   `/root/`、`/home/`、`/Users/`、`C:\`
 · `TestExampleGoModUsesRelativeSDKReplace`:replace 必须是相对路径 `../../`
 · `TestExamplePlgJsonDoesNotPinSDKVersion`:plg.json 不得声明 sdk

变异 2/2 咬住(重新引入绝对路径 / 重新声明 sdk,都准确报出文件路径)。

## 本地验证(模拟 CI 干净环境)

用干净 HOME 跑 `package/build-examples.sh`:
 · 修复前:成功 18 / 失败 4(commondev, editdoc, browser, rss)
 · 修复后:成功 20 / 失败 2(browser, rss —— **本机代理不通**,
   `proxy.golang.org` 超时;用本地 modcache 验证这两个可正常构建)

即 22/22 源码都能构建,CI 有网络不受影响。

## 教训

`git add -A` 会把「构建工具自动改写的文件」一起提交。
提交前必须 `git status` 逐个看,尤其 go.mod / go.sum。

---------

Co-authored-by: JianFeeeee <JianFeeeee@users.noreply.gitcode.com>
2026-10-17 19:44:15 +08:00

295 lines
9.5 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package main
// rawfile:把**非代码资源**打进 .hmap,解包后供插件在运行期读取。
//
// ## 为什么需要
//
// 在它之前 `hmapdev build` 只收 `plugin.bin` + `plugin.json`(+README/LICENSE),
// **任何资源都得靠部署脚本手工拷** —— 那让"一个 .hmap 就是完整插件"这个
// 前提失效:白板机器上装完直接缺文件。
//
// 典型需求:随包分发的 node 与 pi、各 CLI 后端的 sidecar 脚本与 driver.json、
// 模板、prompt 文本、字典、默认配置、wasm、字体……
//
// ## ★ 规则(用户拍板 2026-10-15)
//
// 1. 工程根下的 `rawfile/` 目录 **默认全部打包**(不需要在 plg.json 里声明)。
// 2. 其他位置的资源,用户在 plg.json 的 `rawfiles` 数组里**显式列出**,一同打包。
// 3. 解包后**一律落在 `<插件目录>/rawfile/` 下**:
// · `rawfile/` 目录里的内容 → `<插件目录>/rawfile/<原名>`
// · 外部声明的路径 → `<插件目录>/rawfile/<声明名>`
//
// 即 `rawfile/` 是**唯一入口**,插件只需认一个目录。
//
// ## plg.json 形状
//
// {
// "rawfiles": [
// "config/defaults.json", // 字符串:源路径,落到 rawfile/defaults.json
// { "from": "assets", "exclude": ["assets/tmp"] }
// ]
// }
//
// ## 为什么统一落到 rawfile/ 下
//
// 若允许任意目标路径(比如把 node 直接放到 `runtime/node/bin/node`),
// 就会出现两类路径(工程路径 vs 包内路径)互相穿模,且**与插件自己写的
// 运行期文件混在一起**——升级时无法区分"包里带的"与"运行期生成的"。
// 单一入口让"包内的"与"运行期的"天然分离:包内只读,运行期在别处。
import (
"archive/zip"
"encoding/json"
"fmt"
"io"
"os"
"path/filepath"
"sort"
"strings"
)
// RawFilesDir 是默认打包目录(工程根下)。
const RawFilesDir = "rawfile"
// RawFilesPrefix 是解包后这些资源的统一前缀。
const RawFilesPrefix = "rawfile"
// RawFileDecl 是一条 rawfiles 声明(支持字符串与对象两种写法)。
type RawFileDecl struct {
From string `json:"from"`
Exclude []string `json:"exclude,omitempty"`
Optional bool `json:"optional,omitempty"`
}
// UnmarshalJSON 同时接受 `"path"` 与 `{"from": "path"}` 两种写法。
//
// 为何两种都收:只要一个路径时写字符串最省事(`"config/x.json"`),
// 需要 exclude/optional 时才用对象。强迫所有人写对象是没必要的仪式。
func (d *RawFileDecl) UnmarshalJSON(b []byte) error {
s := strings.TrimSpace(string(b))
if strings.HasPrefix(s, `"`) {
var only string
if err := json.Unmarshal(b, &only); err != nil {
return err
}
d.From = only
return nil
}
type alias RawFileDecl
var a alias
if err := json.Unmarshal(b, &a); err != nil {
return err
}
*d = RawFileDecl(a)
return nil
}
type rawEntry struct {
src string // 磁盘路径
zip string // zip 内条目名(保留路径)
}
// collectRawFiles 收集要打包的资源。
//
// 两部分合起来:
//
// ① 工程根的 `rawfile/` 目录(默认全收)
// ② plg.json 里 rawfiles 声明的外部路径
//
// 输出按 zip 条目名排序(**确定性**:同样输入产出同样字节,
// 否则每次构建的 .hmap 都不同,校验和与 diff 全无意义)。
func collectRawFiles(root string, decls []RawFileDecl) ([]rawEntry, error) {
var out []rawEntry
// ① 默认目录
defDir := filepath.Join(root, RawFilesDir)
if fi, err := os.Stat(defDir); err == nil && fi.IsDir() {
err := filepath.Walk(defDir, func(p string, fi os.FileInfo, werr error) error {
if werr != nil {
return werr
}
if fi.IsDir() {
return nil
}
rel, err := filepath.Rel(defDir, p)
if err != nil {
return err
}
// ★ 排除脚手架文件:`.gitkeep`/`.gitignore` 是**目录占位与版本控制**
// 用的,不是资源。不打进包的原因有两个:
// ① 它们是工程内部约定,对插件运行期毫无意义;
// ② 它们是**噪声** —— 一个空工程会打出"已打包 2 个 rawfile",
// 让人误以为里面有资源。
// (实测发现:init 出的新工程第一次 build 就把这两个文件打进去了。)
if IsRawFileScaffold(rel) {
return nil
}
out = append(out, rawEntry{
src: p,
zip: filepath.ToSlash(filepath.Join(RawFilesPrefix, rel)),
})
return nil
})
if err != nil {
return nil, fmt.Errorf("展开 %s/ 失败: %w", RawFilesDir, err)
}
}
// ② 显式声明
for _, d := range decls {
from := strings.TrimSpace(d.From)
if from == "" {
return nil, fmt.Errorf("rawfiles 有条目缺 from")
}
// ★ from 的语义:**相对 plg.json 所在目录(工程根)**。
//
// 绝对路径**不接受** —— 不是因为技术上做不到,而是它会引入一个
// 无法判定合法性的输入:`/etc/passwd` 与 `/home/me/proj/x` 在语法上
// 没有区别,只能靠"是否在工程内"事后判断,而那个判断依赖 cwd
// (构建时 cwd 不保证是工程根)⇒ 同一个 plg.json 在不同目录下构建
// 结果不同,**构建不可复现**。
//
// 所以:拒绝绝对路径,只接受相对路径。这样 Clean 后前缀比较就是完备的
// (实测发现:初版允许绝对路径,`"a/../../../etc/passwd"` 能绕出去)。
if filepath.IsAbs(from) {
return nil, fmt.Errorf(
"rawfiles 不接受绝对路径: %q(请写成相对工程根的路径)", from)
}
abs := filepath.Join(root, from)
cleanRoot := filepath.Clean(root)
cleanAbs := filepath.Clean(abs)
if cleanAbs != cleanRoot && !strings.HasPrefix(cleanAbs, cleanRoot+string(os.PathSeparator)) {
return nil, fmt.Errorf(
"rawfiles 路径逃出工程目录: %q(资源必须放在工程内)", from)
}
info, err := os.Stat(abs)
if err != nil {
if d.Optional {
fmt.Printf(" warn: rawfile 可选源缺失,跳过: %s\n", from)
continue
}
return nil, fmt.Errorf("rawfiles 源不存在: %s(要允许缺失请加 optional)", from)
}
base := filepath.Base(filepath.Clean(from))
if !info.IsDir() {
// 显式声明也排除脚手架(同一套规则,避免"两条来源行为不一致")
if IsRawFileScaffold(from) {
continue
}
out = append(out, rawEntry{
src: abs,
zip: filepath.ToSlash(filepath.Join(RawFilesPrefix, base)),
})
continue
}
err = filepath.Walk(abs, func(p string, fi os.FileInfo, werr error) error {
if werr != nil {
return werr
}
if fi.IsDir() {
return nil
}
rel, err := filepath.Rel(abs, p)
if err != nil {
return err
}
relSlash := filepath.ToSlash(rel)
if IsRawFileScaffold(relSlash) {
return nil
}
for _, ex := range d.Exclude {
ex = filepath.ToSlash(strings.TrimSpace(ex))
if ex != "" && strings.HasPrefix(relSlash, ex) {
return nil
}
}
out = append(out, rawEntry{
src: p,
zip: filepath.ToSlash(filepath.Join(RawFilesPrefix, base, rel)),
})
return nil
})
if err != nil {
return nil, fmt.Errorf("展开 rawfiles 目录 %s 失败: %w", from, err)
}
}
sort.Slice(out, func(i, j int) bool { return out[i].zip < out[j].zip })
// 撞名检测:两条来源落到同一包内路径 —— 不报的话"我改的没生效"极难查
seen := map[string]string{}
for _, e := range out {
if prev, dup := seen[e.zip]; dup {
return nil, fmt.Errorf("rawfile 目标冲突: %q 同时来自 %s 与 %s", e.zip, prev, e.src)
}
seen[e.zip] = e.src
}
return out, nil
}
// writeRawEntries 把资源写进 zip(**保留路径**)。
//
// ★ 与既有 createHmap 的区别:那个用 `filepath.Base(path)` 当条目名
// (只适合单文件),目录进去会全部拍平撞名。rawfile 必须保留结构,
// 否则 `rawfile/main/foo.json` 会变成 `foo.json` 落在插件目录根上。
func writeRawEntries(w *zip.Writer, entries []rawEntry) error {
for _, e := range entries {
info, err := os.Stat(e.src)
if err != nil {
return fmt.Errorf("rawfile 源消失: %s: %w", e.src, err)
}
hdr, err := zip.FileInfoHeader(info)
if err != nil {
return err
}
hdr.Method = zip.Deflate
hdr.Name = e.zip // ← 完整路径,不是 Base
writer, err := w.CreateHeader(hdr)
if err != nil {
return err
}
src, err := os.Open(e.src)
if err != nil {
return err
}
if _, err := io.Copy(writer, src); err != nil {
src.Close()
return err
}
src.Close()
}
return nil
}
// rawFilesSummary 给构建输出一行摘要。
func rawFilesSummary(entries []rawEntry) string {
if len(entries) == 0 {
return ""
}
var total int64
for _, e := range entries {
if fi, err := os.Stat(e.src); err == nil {
total += fi.Size()
}
}
return fmt.Sprintf("%d 个 rawfile(%.1f MiB)", len(entries), float64(total)/1048576)
}
// IsRawFileScaffold 判断某个**相对路径**是否是版本控制/目录占位用的脚手架文件。
//
// 这类文件随工程存在,但不是随包资源:
// - `.gitkeep` 空目录占位
// - `.gitignore` rawfile/ 自带的忽略规则
// - `.DS_Store` macOS Finder 的目录元数据(常被误提交)
//
// 排除它们既是"别把工程内部约定发给运行期",也是**去噪**:
// 不排的话一个空工程会报"已打包 2 个 rawfile",误导开发者以为包里有东西。
func IsRawFileScaffold(rel string) bool {
base := filepath.Base(filepath.ToSlash(rel))
switch base {
case ".gitkeep", ".gitignore", ".DS_Store":
return true
}
// 任意层级下的这些名字都不算资源
return false
}