第三篇 5 天 12 个 commit:踩坑实录与代码演进

Solo Workspace 在真实开发中遇到的 3 个教训。


第一篇讲了为什么做------被碎片化工具折磨够了。

第二篇讲了怎么做------Go + 插件架构 + 配置链。

这篇不讲理念了,讲真实 commit 里的坑。5 天,12 个 commit,其中 3 个是在修自己前一天写出来的 bug。

按时间线来吧。


一、Day 3 推翻了自己的设计

Day 1 写配置系统的时候,想法很简单:

arduino 复制代码
// (Day 1 的心理活动,不是真实代码)
// 所有配置放 ~/.solo/config.yaml → 完了

因为工具是给自己用的嘛。自己用,一个固定目录就够了,不需要什么加载链、优先级、自定义路径。简洁、够用。

两天后来了两件事,把这条设计打翻了。

第一件事:朋友问了个问题

早期版本做出来后,我发给一个朋友试用。

他说:"我有两个项目,一个用生产服务器,一个用测试服务器,配置怎么分开?"

我沉默了大概 10 秒。因为答案很尴尬------分不开。 所有东西都在 ~/.solo/ 里,要么全有要么全无。

第二件事:我想让 sw 给 AI agent 用

我在把自己的工作流接入 AI agent。agent 需要能执行命令、读配置、管理环境变量。

但如果 agent 也能读到 ~/.solo/ 里的全部数据------包括加密 vault------那加密就形同虚设了。

我需要一个能力:agent 调用 sw 时,只能看到它被允许看到的那部分数据。

这两个需求指向同一个解决方案:多配置 + 数据跟随配置。

重构后的设计

javascript 复制代码
-c <path> 指定  → 最高优先级(AI agent 专属配置)
~/.solo/config.yaml  → 全局默认(人用)
.solo.yaml (当前目录)  → 项目级配置

AI agent 调 sw 的时候传 -c /tmp/workspace/sw-config.yaml,它读到的只是这个局部配置,碰不到我个人的 vault 和 secrets。

然后密码和 token 这些敏感信息怎么处理?不放在 config.yaml 里,放在一个独立的加密文件中跟随配置目录走:

go 复制代码
// fileutil.go
var SecretResolver func(name string) (string, error)

SecretResolver 是一个插拔函数------在 init() 里由 secret 插件注册。config 层不关心密码存在哪、怎么解密,它只管问。

EmailConfig 里有个 PasswordSecret 字段:

go 复制代码
type EmailConfig struct {
    // ...
    Password       string `yaml:"password,omitempty"`
    PasswordSecret string `yaml:"password_secret,omitempty"` // vault key
}

SMTP 密码不写明文在 YAML 里,而是引用 vault 中的密钥名。解析时自动解析:

go 复制代码
func resolveEmailPassword(email *EmailConfig) (string, error) {
    if email.PasswordSecret != "" {
        return SecretResolver(email.PasswordSecret)
    }
    return email.Password, nil
}

还顺手解决了一个问题

原来 ServerConfig 只定义了 host/user/port 三个字段。后来发现不同服务器要记录不同信息------有的需要 key_path,有的需要 jump_host,有的需要打标签。

当时想:"要不要再加 5 个字段?不然下次又要改结构体。"

想了想,加了一个 Extra

go 复制代码
type ServerConfig struct {
    Host  string                 `yaml:"host"`
    User  string                 `yaml:"user"`
    Port  int                    `yaml:"port"`
    Extra map[string]interface{} `yaml:"-"` // 未知字段自动落在这
}

然后自定义了 UnmarshalYAML:识别出来的三个字段进结构体,剩下的全塞进 Extra

效果:用户写 YAML 时随意加字段,都不会报错,都存在 Extra 里。

yaml 复制代码
servers:
  prod:
    host: 192.168.1.1
    user: root
    port: 22
    key_path: ~/.ssh/prod_key   # 自动进 Extra
    tag: production              # 自动进 Extra

回头看:不是"今天觉得完美明天推翻",而是"自己给自己提了两个需求,一个方案解决了。" 给 AI agent 用不是后来才附加上去的场景,它从一开始就在驱动设计------只不过 Day 1 的我自己还没意识到。


二、一个 ANSI 颜色泄露 bug

某天 sw todo list 出来了一堆奇怪的输出。

done 这个绿色单词后面的整行文字,全部变成了绿色。pending 后面的整行变成了黄色。

排查流水账

第一反应:是不是 fatih/color 的锅?

看了下源码,这个库就是给字符串包一层 ANSI code,不会自己泄露。不是它的问题。

第二反应:是不是终端模拟器的 bug?

iTerm2 和 Terminal 都试了,同样的现象。不是。

第三反应:缩小现象范围。

发现只出现在"单元格内容被截断"的场景------也就是 truncateVisible() 函数被调用的时候。

第四反应:细看 truncateVisible

go 复制代码
func truncateVisible(s string, max int) string {
    // 截断逻辑......没问题
    // 但截断之后,ANSI 颜色代码被截不见了?
}

不对,逻辑是对的。ANSI 代码在截断前就被剥离和恢复了。

第五反应:看 Table() 渲染函数。

问题出来了:

csharp 复制代码
func Table(header []string, rows [][]string) {
    // 渲染每个单元格
    out.WriteString(padRight(cell, colWidths[i]))
​
    // ......没有颜色重置
}

TodoStatus("done") 返回的是 \033[32mdone\033[0m,完整、正确。但如果这个单元格刚好被 padRight 填充了一段时间后,下一个单元格的内容紧接着输出------如果上一个 单元格带了颜色,而 padRight 只填了空格(不包含颜色重置),颜色就"流"到下一个单元格了。

实际上是 truncateVisible 截断后,结尾的 \033[0m 也被截掉了一部分,导致颜色代码没有正确闭合。修复很简单:

csharp 复制代码
// 在每个彩色单元格后加颜色重置,防止颜色泄露
if ansiRE.MatchString(cell) {
    out.WriteString("\x1b[0m")
}

就这 3 行代码。

代价

这个 bug 从发现到定位,大概花了 40 分钟。最终改动:3 行

在 VSCode 的终端里从来没看到过这个问题,因为我的终端够宽,不需要截断。但在 iTerm2 里调整了一下窗口大小就出现了。

教训:CLI 的表格渲染坑比想象的多。 ANSI 代码不是可见字符,但它们会偷偷改变后续输出的显示效果。在"自己的终端、自己的配置、自己的数据"下自测通过,不代表在别的环境下不出问题。


三、中文宽度与自适应表格

项目列表的表格排序看起来总是有些不对劲。

bash 复制代码
ID │ Path                                  │ Description
───┼───────────────────────────────────────┼─────────────
1  │ /Users/shenyb/work/source/solo-workspace │ Solo Workspace
2  │ /Users/shenyb/Project/中文项目              │ 中文项目

第 2 行的列宽偏移了一个字符的位置,不多不少,就是一个中文字符的宽度。

原因简单到令人沮丧:中文字符的显示宽度是 2,但 Go 的 len() 返回 1。 padRight 按字符数填充空格,中文字符出现后,列宽计算全错。

修复

引入 go-runewidth 库。核心变化就是 visibleLen() 替代 len()

go 复制代码
func visibleLen(s string) int {
    return runewidth.StringWidth(stripANSI(s))
}

stripANSI 要先于宽度计算------ANSI 代码也不贡献宽度,但 len() 会算它们。

自适应:终端缩窄了怎么办

这是另一个人写的场景:ssh 到服务器上,终端只有 80 列宽。

Solo Workspace 的所有表格输出都通过 Table() 函数,它有个自动适应机制:

  1. 按内容算每列自然宽度
  2. 单列上限 80 字符
  3. 总宽度超出终端宽度 → 比例压缩
  4. 用 ceil 除法 (w*avail+total-1)/total,防止短列压缩到 0
  5. 最小列宽 6 字符
css 复制代码
if totalNeeded > termWidth {
    available := termWidth - (numCols-1)*sepLen
    total := 0
    for _, w := range colWidths { total += w }
    if total > 0 {
        for i := range colWidths {
            colWidths[i] = (colWidths[i]*available + total - 1) / total
            if colWidths[i] < 6 {
                colWidths[i] = 6
            }
        }
    }
}

那个 ceil 除法(total - 1 的细节)是为了防止类似"标签"这种本来就短的列被压到 0------如果 "pending" 这个 7 字符的标签被直接压没了,那表格就不知道这一列是什么状态了。

中英文混合的实际效果

现在 describe 那列有中文也不怕了。truncateVisible 截断时按显示宽度算,而不是按字符个数截:

go 复制代码
func truncateVisible(s string, max int) string {
    // 保留前导 ANSI 代码(颜色不能被截没)
    // 然后按 runewidth 一个一个截
    // 最后一个字符放不下就补个省略号
}

教训:自己用的工具,"我看着没问题"是不够的。 项目路径里有中文、ssh 到服务器上终端只有 80 列、数据多了被截断------这些场景在"自测"时不会自然覆盖到,但用户一用就暴露了。


回头看那 12 个 commit

翻 git log 看了下分布:

  • 3 个是推翻自己前一天的设计
  • 2 个是修"写之前根本不知道存在"的 bug
  • 4 个是加边角料功能(补文档、修 typo、加默认值)
  • 剩下的就是在干正事

不是项目复杂,而是小项目里每个细节都必须自己处理。没有 PM 挡需求、没有 QA 报 bug、没有同事 review。每个坑都是自己踩进去再爬出来的。

但换个角度想,踩坑本身也是一种设计输入。 第 3 天的重构如果没有第 1 天的"写死"经验,也不会知道灵活加载链到底解决了什么问题。ANSI 泄漏的 bug 如果不修,就不会意识到 "颜色代码对宽度计算是透明的"这件事。


下篇预告

第四篇讲讲真实使用体验:自己写的工具,自己用了一个月,到底好不好?有没有哪些功能是写完了但一次都没用过的?哪里不爽?

有人来看再继续写。


GitHub: github.com/shenyb/solo...

上一篇:为什么是 Go?插件架构怎么设计?

下一篇:用自己写的工具管理自己的项目


这是 Solo Workspace 开发实录的第三篇。

相关推荐
Solis1 小时前
Raft:分布式系统的定海神针
后端·架构
程序员鱼皮1 小时前
提示词工程已死,Loop Engineering 称王!保姆级教程 + 项目实战
前端·后端·ai编程
LeahDizon2 小时前
AI Coding 协作实践方案
程序员·github·代码规范
Mininglamp_27182 小时前
Vibe Coding 之后是 Vibe Operating?
后端·开源·多智能体·ai agent·mano-p
星哥的编程之路2 小时前
别再调 API 就说自己会 RAG 了,看看真正的企业级 AI 智能体长什么样
后端·面试
长大19882 小时前
C++26 静态反射完整实战:告别宏代码生成,一键实现序列化
后端
yb7792 小时前
Java 21 虚拟线程最佳实践:虚拟线程如何让高并发 Java 服务更轻更快
后端
fliter2 小时前
绕过系统 ICMP:用 rawsock、Npcap 和 WMI 找到默认网卡
后端
AHRIKNOW2 小时前
AFaster:一个开箱即用的 Rust 高性能后端框架模板
后端