@dsh-workbench/core 0.3.0 → 0.3.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +6 -2
- package/bin/dsh-workbench +17 -15
- package/client.js +359 -64
- package/dist/client/client.body.js +359 -64
- package/dist/client/index.js +359 -64
- package/dist/host/index.js +443 -385
- package/dist/types/app/client.d.ts +3 -0
- package/dist/types/app/layouts/rightbar/BoardGrid.d.ts +23 -0
- package/dist/types/app/layouts/rightbar/board-columns.d.ts +17 -4
- package/dist/types/app/layouts/rightbar/rightbar-width.d.ts +16 -0
- package/dist/types/app/layouts/rightbar/rightbar-width.test.d.ts +1 -0
- package/dist/types/app/layouts/rightbar/use-board-split.d.ts +8 -0
- package/dist/types/modules/panels/model/diy-report.d.ts +13 -2
- package/dist/types/shared/api/host/diy-workshop.d.ts +13 -0
- package/dist/types/shared/api/host/runtime-seed.d.ts +0 -2
- package/docs/HANDOFF.md +11 -1
- package/docs/OPERATIONS.md +43 -0
- package/package.json +2 -2
- package/scripts/seed-workshop.sh +461 -13
- package/skills/workbench-module-diy/BUILD.md +6 -1
package/scripts/seed-workshop.sh
CHANGED
|
@@ -12,7 +12,8 @@
|
|
|
12
12
|
#
|
|
13
13
|
# 用法:
|
|
14
14
|
# bash scripts/seed-workshop.sh # 完整:骨架(缺才建)+ overrides + 依赖(缺才装)+ 入口
|
|
15
|
-
# bash scripts/seed-workshop.sh entry #
|
|
15
|
+
# bash scripts/seed-workshop.sh entry # 只写入口(每次启动用);顺手比一次代际,不一致就后台 sync
|
|
16
|
+
# bash scripts/seed-workshop.sh sync # 自愈:把工坊那三个包对齐到**当前外壳**那一代(后台跑)
|
|
16
17
|
#
|
|
17
18
|
# 幂等:存在的文件不覆盖、依赖在就跳过安装、重复跑不产生副作用。
|
|
18
19
|
|
|
@@ -43,6 +44,448 @@ else
|
|
|
43
44
|
IN_REPO=0
|
|
44
45
|
fi
|
|
45
46
|
|
|
47
|
+
# ── 工坊与「现在跑着的外壳」是不是同一代 ─────────────────────────────────────
|
|
48
|
+
#
|
|
49
|
+
# 不变量:工坊里那三个 @dsh-workbench/* 必须与**当前运行的外壳**同代。不同代时工具链会
|
|
50
|
+
# 静默地给出旧答案 —— 判据、模板、报错话术都属于它那一代,而 agent 读到的像是"你写的
|
|
51
|
+
# 声明错了"(实测:0.1.13 与 0.1.14 两台工具并存,全程一声不响)。
|
|
52
|
+
#
|
|
53
|
+
# **基准是显式输入**:`DSH_WB_SHELL_ROOT` = 正在跑的那份外壳的包根。谁在跑只有调用者知道 ——
|
|
54
|
+
# 宿主自举给它自己的包根、启动器给 profile 里那份 `node_modules/@dsh-workbench/core`。
|
|
55
|
+
# **跑脚本这份副本不算数**,这条是实测踩出来的(同一天两次):启动器那份 0.3.0、profile 里
|
|
56
|
+
# 真正加载的是 0.3.1、工坊已经跟到 0.3.1 —— 启动器先跑 entry(它在 `exec dsh` 之前),拿自己
|
|
57
|
+
# 那份当基准,于是把工坊**降级**到 0.3.0;宿主随后再跑 entry,看到工坊"与它同代" → no-op,
|
|
58
|
+
# 整场会话都停在错的一代。
|
|
59
|
+
#
|
|
60
|
+
# 三条纪律:
|
|
61
|
+
# · 判据只认**实装版本**(工坊 node_modules 里的 package.json);`.sync.json` 只用于退避
|
|
62
|
+
# 与说人话,绝不拿它当"装好了"的证据;
|
|
63
|
+
# · 启动路径(entry)只做一次**便宜比对**,不一致就后台自愈,不许把启动拖住 ——
|
|
64
|
+
# 宿主每次启动都会跑 entry(runtime-seed 的 entry plan),所以这里挂一下是零新增调用点;
|
|
65
|
+
# · 复核过才写 ok —— 不信 pnpm 的退出码(同 self-update 的 on_disk 复核、ensure-selected 的复核)。
|
|
66
|
+
#
|
|
67
|
+
# **没有基准就什么都不做**:不读工坊实装版本、不起 sync、不写 `.sync.json`、不动工坊一个字节。
|
|
68
|
+
# 这正是 `setup`(seed-profile.sh)那趟的情形 —— 那时 profile 里那份还没装/还是旧的,
|
|
69
|
+
# 代际对齐归"启动时"。**基准从不取"跑脚本那份副本"**,否则谁后跑谁说了算。
|
|
70
|
+
#
|
|
71
|
+
# 冻结接口:状态文件 $DSH_HOME/workbench/diy/.sync.json =
|
|
72
|
+
# { state: "ok"|"syncing"|"failed", version: <基准版本>, at: <ISO>, reason?: <一句话> }
|
|
73
|
+
SHELL_ROOT="${DSH_WB_SHELL_ROOT:-}"
|
|
74
|
+
DIY_DIR="$DSH_HOME/workbench/diy"
|
|
75
|
+
SEED_LOG="$DSH_HOME/workbench/seed.log"
|
|
76
|
+
SYNC_STATE="$DIY_DIR/.sync.json"
|
|
77
|
+
SYNC_LOCK="$DIY_DIR/.sync.lock"
|
|
78
|
+
SYNC_BACKOFF_SECONDS=3600
|
|
79
|
+
SYNC_LOCK_STALE_MINUTES=15
|
|
80
|
+
SYNC_LOCK_WAIT_SECONDS=5
|
|
81
|
+
|
|
82
|
+
# 基准版本:只有**显式给了基准**、且它有可读的 package.json 时才有值。空 = 这一轮不做比对。
|
|
83
|
+
shell_version() {
|
|
84
|
+
[ -n "$SHELL_ROOT" ] || return 0
|
|
85
|
+
[ -f "$SHELL_ROOT/package.json" ] || return 0
|
|
86
|
+
node -p "require('$SHELL_ROOT/package.json').version" 2>/dev/null || true
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
# 跑脚本这份副本自己的版本:**只给骨架用**(setup 那趟没有基准,而它要写下的正是"这份副本
|
|
90
|
+
# 这一代" —— profile 就是照它装的)。比对**永远不用**它:它可能比 profile 里那份旧(见上面那段)。
|
|
91
|
+
copy_version() {
|
|
92
|
+
node -p "require('$REPO_DIR/package.json').version" 2>/dev/null || true
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
# 要装的**目标版本**:有基准用基准;没基准(setup 建骨架)用这份副本。
|
|
96
|
+
target_version() {
|
|
97
|
+
local version
|
|
98
|
+
version="$(shell_version)"
|
|
99
|
+
[ -n "$version" ] || version="$(copy_version)"
|
|
100
|
+
printf '%s\n' "$version"
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
# 三个包的装法**只有一处真相**:仓库副本 link 进本仓,安装副本按目标版本装。
|
|
104
|
+
# skeleton 与 sync 都调它 —— 两份规则漂开的症状是"根清单是 link、overrides 是 ^",
|
|
105
|
+
# 于是同一个工坊里两代并存(正是要防的那件事),而且不报错。
|
|
106
|
+
#
|
|
107
|
+
# 这一套值**同时**写进两个地方(devDependencies 与 pnpm.overrides),所以它必须是两边都常规的形状:
|
|
108
|
+
# · 安装态 = 纯范围 `^<版本>`。**别写成 npm 的具名 spec `@包名@^版本`**:那形状放进 overrides 要再
|
|
109
|
+
# 过一遍 pnpm 的 spec 解析,同一形状还可能被当 alias 处理,而 `"@scope/pkg": "^1.2.3"` 才是
|
|
110
|
+
# overrides 的常规写法 —— 这是每次安装都走的 happy path(Lead 的独立探针抓到的就是这一处)。
|
|
111
|
+
# · 仓库态 = `link:<路径>`:link 本仓那份,改工具链源码工坊当场生效。
|
|
112
|
+
compute_specs() {
|
|
113
|
+
if [ "$IN_REPO" = "1" ]; then
|
|
114
|
+
CORE_SPEC="link:$REPO_DIR"
|
|
115
|
+
TOOLS_SPEC="link:$(cd "$REPO_DIR/../module-tools" && pwd)"
|
|
116
|
+
SHARED_SPEC="link:$(cd "$REPO_DIR/../shared" && pwd)"
|
|
117
|
+
else
|
|
118
|
+
local version="${1:-}"
|
|
119
|
+
[ -n "$version" ] || version="$(target_version)"
|
|
120
|
+
[ -n "$version" ] || version=0
|
|
121
|
+
CORE_SPEC="^$version"
|
|
122
|
+
TOOLS_SPEC="^$version"
|
|
123
|
+
SHARED_SPEC="^$version"
|
|
124
|
+
fi
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
# 日志**只有一处**去处:$DSH_HOME/workbench/seed.log(冻结接口)。
|
|
128
|
+
# 后台那条路的 stdout/stderr 已被入口重定向进同一个文件,所以这里只在**终端**上补一份回显
|
|
129
|
+
# (`[ -t 1 ]` 为假 = 有人正把输出收进文件,那就别再写第二遍)。
|
|
130
|
+
#
|
|
131
|
+
# ⚠️ 变量后面紧挨着中文时一律写 `${VAR}`:这台机器的 bash 是 3.2(macOS 自带那份),
|
|
132
|
+
# 它会把多字节字符的**头一个字节**吃进变量名 —— 实测 `$shared_v;` 报 `shared_v?: unbound
|
|
133
|
+
# variable`(`set -u` 下当场死),而同一份脚本在 bash 5 上没事。下面每条话术都照这个写。
|
|
134
|
+
log_line() {
|
|
135
|
+
mkdir -p "$(dirname "$SEED_LOG")" 2>/dev/null || true
|
|
136
|
+
printf '%s\n' "$*" >>"$SEED_LOG" 2>/dev/null || true
|
|
137
|
+
if [ -t 1 ]; then printf '%s\n' "$*"; fi
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
# ── 工坊**自己带着** DIY 的指引:这条软链只有一份写口 ──────────────────────────
|
|
141
|
+
#
|
|
142
|
+
# 指引住进工坊的理由:DIY 会话的 cwd 就是工坊,而 dsh 的 skill-filesystem 是从 **cwd 下的
|
|
143
|
+
# `.agents/skills/`** 发现技能的(本仓自己那条 `module-design` 就是这么被看见的)—— 于是
|
|
144
|
+
# 进工坊的会话天然看得见它,**不用去改用户任何默认设置**(改默认 preset 会连带把用户后面
|
|
145
|
+
# 新建的普通会话也变成 DIY 模式)。
|
|
146
|
+
#
|
|
147
|
+
# ⚠️ 写口只有这一处,而且故意放在**本脚本**里:启动器(`bin/dsh-workbench`)与宿主自举
|
|
148
|
+
# (`src/shared/api/host/runtime-seed.ts`)都会跑它,于是"挂指引"只有一份实现。曾经是两处
|
|
149
|
+
# 各写一遍(启动器一段 bash + TS 的 `linkSkills`),写的是同一个路径、目标也一致 —— 那是
|
|
150
|
+
# **同一件事两个实现**:哪天只有一边改了源或判据,就会每次启动互相改指,症状是"会话里那份
|
|
151
|
+
# 指引时新时旧",还是**不报错**(软链指到哪 skill 发现都不吭声)。
|
|
152
|
+
#
|
|
153
|
+
# 源优先**基准**(正在跑的那份外壳,由调用者显式给)那份,没有才退回跑脚本这份副本;
|
|
154
|
+
# 两者都没有(包还没做完)就**不挂** —— 挂出来是条死链,而 skill 发现对死链不报错。
|
|
155
|
+
# 幂等:`readlink` 已经等于源就**一个字节不写**(entry 每次启动都会跑到这里)。
|
|
156
|
+
# 这条与代际无关:**不要**把它挂在"有基准"的判断上 —— 挂指引不是代际决策。
|
|
157
|
+
link_workshop_skills() {
|
|
158
|
+
local src="$REPO_DIR/skills/workbench-module-diy"
|
|
159
|
+
if [ -n "$SHELL_ROOT" ] && [ -d "$SHELL_ROOT/skills/workbench-module-diy" ]; then
|
|
160
|
+
src="$SHELL_ROOT/skills/workbench-module-diy"
|
|
161
|
+
fi
|
|
162
|
+
[ -d "$src" ] || return 0
|
|
163
|
+
|
|
164
|
+
local link="$DIY_DIR/.agents/skills/workbench-module-diy"
|
|
165
|
+
if [ "$(readlink "$link" 2>/dev/null || true)" = "$src" ]; then return 0; fi
|
|
166
|
+
# 那儿要是别人的**真目录**(不是我们建的软链)就不碰它:`ln -s` 会往它里面再套一层。
|
|
167
|
+
if [ -e "$link" ] && [ ! -L "$link" ]; then
|
|
168
|
+
echo "→ ⚠️ $link 已存在且不是软链 —— 不动它(手工处理,或先把它挪走);这条不影响启动。" >&2
|
|
169
|
+
return 0
|
|
170
|
+
fi
|
|
171
|
+
|
|
172
|
+
# 先 rm -f:旧软链还在时 `ln -s` 会顺着它把新链**套进去**(symlink 指向 symlink)。
|
|
173
|
+
mkdir -p "$(dirname "$link")" 2>/dev/null || true
|
|
174
|
+
rm -f "$link" 2>/dev/null || true
|
|
175
|
+
if ln -s "$src" "$link" 2>/dev/null; then
|
|
176
|
+
echo "→ 工坊指引已链上:$link → $src"
|
|
177
|
+
else
|
|
178
|
+
# 挂不上只留一句话,不当场炸:这条是"锦上添花",不该把启动带崩(自举同理)。
|
|
179
|
+
echo "→ ⚠️ DIY 指引没挂上(${link})—— 手工从 ${src} 建一条软链即可;这条不影响启动。" >&2
|
|
180
|
+
fi
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
# 一次 node 调用把四份 package.json 的版本读出来(基准 / core / module-tools / shared,一行一份)。
|
|
184
|
+
# 为什么合成一次:entry 在启动路径上,不该为比一次版本起四次进程。读不到的给空行 —— 空 =
|
|
185
|
+
# "工坊还没装好 / 这个包不在",是**一个**信号,调用方按"什么都不做"处理。
|
|
186
|
+
# 第一行读的是**基准**($SHELL_ROOT),不是跑脚本这份副本 —— 见上面那段漂移实测。
|
|
187
|
+
read_generations() {
|
|
188
|
+
node -e '
|
|
189
|
+
const fs = require("node:fs");
|
|
190
|
+
const path = require("node:path");
|
|
191
|
+
const versionOf = (file) => {
|
|
192
|
+
try { return String(JSON.parse(fs.readFileSync(file, "utf8")).version || ""); } catch { return ""; }
|
|
193
|
+
};
|
|
194
|
+
const [shellRoot, diy] = process.argv.slice(1);
|
|
195
|
+
const installed = (name) => versionOf(path.join(diy, "node_modules", "@dsh-workbench", name, "package.json"));
|
|
196
|
+
// 空基准要显式挡住:`path.join("", "package.json")` 会去读**当前目录**那份,那是巧合不是判据。
|
|
197
|
+
const shell = shellRoot === "" ? "" : versionOf(path.join(shellRoot, "package.json"));
|
|
198
|
+
process.stdout.write([shell, installed("core"), installed("module-tools"), installed("shared")].join("\n") + "\n");
|
|
199
|
+
' "$SHELL_ROOT" "$DIY_DIR" 2>/dev/null || true
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
# 退避:上一轮在**同一代**上失败过、且还在窗口内 → 不重试。
|
|
203
|
+
#
|
|
204
|
+
# 为什么必须退避:entry 每次启动都跑,不自愈的话一个装不动的工坊会在每次启动时后台起一轮
|
|
205
|
+
# pnpm(几十秒 + 网络),用户只觉得"启动变慢了",日志里也看不出是谁。窗口按**版本**算:
|
|
206
|
+
# 外壳换了一代就是新问题,哪怕上一代失败过也要重试。
|
|
207
|
+
sync_backoff_active() {
|
|
208
|
+
local version="$1"
|
|
209
|
+
[ -f "$SYNC_STATE" ] || return 1
|
|
210
|
+
node -e '
|
|
211
|
+
const fs = require("node:fs");
|
|
212
|
+
try {
|
|
213
|
+
const state = JSON.parse(fs.readFileSync(process.argv[1], "utf8"));
|
|
214
|
+
if (state.state !== "failed" || state.version !== process.argv[2]) process.exit(1);
|
|
215
|
+
const at = Date.parse(state.at);
|
|
216
|
+
process.exit(Number.isFinite(at) && Date.now() - at < Number(process.argv[3]) * 1000 ? 0 : 1);
|
|
217
|
+
} catch { process.exit(1); }
|
|
218
|
+
' "$SYNC_STATE" "$version" "$SYNC_BACKOFF_SECONDS"
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
# 状态文件的写口只有这一个:手拼 JSON 会在 reason 带引号/换行时写出读不回来的文件。
|
|
222
|
+
write_sync_state() {
|
|
223
|
+
node -e '
|
|
224
|
+
const fs = require("node:fs");
|
|
225
|
+
const [file, state, version, reason] = process.argv.slice(1);
|
|
226
|
+
const body = { state, version, at: new Date().toISOString() };
|
|
227
|
+
if (reason) body.reason = reason;
|
|
228
|
+
fs.writeFileSync(file, JSON.stringify(body, null, 2) + "\n");
|
|
229
|
+
' "$SYNC_STATE" "$1" "$2" "${3:-}"
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
# 原子锁:`mkdir` 是唯一"失败=已被占"的原子原语(`test -e` + 写文件那条路有 TOCTOU)。
|
|
233
|
+
# 两个实例共享同一个 DSH_HOME 时会撞(用户开两份工作台、或启动器与宿主自举同时跑)。
|
|
234
|
+
# 过期判据必须有:持锁者被 kill -9 时不会有人来解锁,没有它工坊会被**永久**锁死。
|
|
235
|
+
#
|
|
236
|
+
# 锁目录里落三样东西:
|
|
237
|
+
# · `target` —— 这一轮要装到**哪一代**。锁的真相是"目标",不是"有人在跑":同一个 DSH_HOME
|
|
238
|
+
# 下两个 profile 版本不同时(dsh web 与独立实例并存)会撞,那时"后到的那一代"不能永远
|
|
239
|
+
# 没发言权 —— 目标不同就等一轮再抢(见 acquire_sync_lock)。
|
|
240
|
+
# · `owner` —— 谁持的锁。过期判据一旦误判、别人清了锁重抢,原持锁者的 EXIT trap 醒来时
|
|
241
|
+
# **不能**去 `rm -rf` 别人的锁(那会把新持锁者的 install 砍在半路)。
|
|
242
|
+
# · `heartbeat` —— "我还在跑"的进度信号(见 sync_lock_stale)。
|
|
243
|
+
lock_field() {
|
|
244
|
+
[ -f "$SYNC_LOCK/$1" ] || return 0
|
|
245
|
+
head -n 1 "$SYNC_LOCK/$1" 2>/dev/null || true
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
# 过期判据按**进度**判活,**不看锁目录的 mtime**:一次 `pnpm install` 跑十几二十分钟是常事,
|
|
249
|
+
# 只看目录 mtime 会把它判成"持锁者已崩" → 别人清锁重抢 → 两个 install 并发(且原持锁者的
|
|
250
|
+
# trap 还会删掉新锁)。持锁者每 30 秒碰一下 `heartbeat`,判据读的是它;没有 heartbeat
|
|
251
|
+
# (老锁 / 刚建好还没来得及碰)才退回锁目录 mtime。
|
|
252
|
+
sync_lock_stale() {
|
|
253
|
+
local stamp="$SYNC_LOCK"
|
|
254
|
+
[ -f "$SYNC_LOCK/heartbeat" ] && stamp="$SYNC_LOCK/heartbeat"
|
|
255
|
+
[ -n "$(find "$stamp" -maxdepth 0 -mmin +"$SYNC_LOCK_STALE_MINUTES" 2>/dev/null)" ]
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
# 强行清锁:只给"判定持锁者已崩"那条路用(那时锁已经不是原主的了)。
|
|
259
|
+
drop_sync_lock() {
|
|
260
|
+
case "${SYNC_LOCK:-}" in
|
|
261
|
+
*/workbench/diy/.sync.lock) rm -rf "$SYNC_LOCK" ;;
|
|
262
|
+
esac
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
# 抢锁的返回值:0 = 抢到;1 = 同一代的人正装着(跳过);2 = 这地方建不出锁;3 = 另一代正装着、等过一轮没抢到。
|
|
266
|
+
acquire_sync_lock() {
|
|
267
|
+
local target="$1" attempt=0 waited=0 held=""
|
|
268
|
+
while [ "$attempt" -lt 2 ]; do
|
|
269
|
+
if mkdir "$SYNC_LOCK" 2>/dev/null; then
|
|
270
|
+
SYNC_LOCK_TOKEN="$$-$(date +%s)-${RANDOM:-0}"
|
|
271
|
+
printf '%s\n' "$target" >"$SYNC_LOCK/target" 2>/dev/null || true
|
|
272
|
+
printf '%s\n' "$SYNC_LOCK_TOKEN" >"$SYNC_LOCK/owner" 2>/dev/null || true
|
|
273
|
+
touch "$SYNC_LOCK/heartbeat" 2>/dev/null || true
|
|
274
|
+
return 0
|
|
275
|
+
fi
|
|
276
|
+
# mkdir 失败**且锁不在** = 这个目录建不出东西(权限 / 只读),不是"别人正持锁" ——
|
|
277
|
+
# 这两种要分开:说成"另一轮正在跑"会让日志把权限问题指成并发问题。
|
|
278
|
+
if [ ! -d "$SYNC_LOCK" ]; then return 2; fi
|
|
279
|
+
if sync_lock_stale; then
|
|
280
|
+
log_line "→ 自愈锁超过 ${SYNC_LOCK_STALE_MINUTES} 分钟没有进度(心跳也没动过),当持锁者已崩,清掉重抢"
|
|
281
|
+
drop_sync_lock
|
|
282
|
+
attempt=$((attempt + 1))
|
|
283
|
+
continue
|
|
284
|
+
fi
|
|
285
|
+
held="$(lock_field target)"
|
|
286
|
+
# 目标是同一代:谁装都一样,让他装完就行,不必排队。
|
|
287
|
+
if [ "$held" = "$target" ]; then return 1; fi
|
|
288
|
+
# 目标是**另一代**:等一小会再抢一次(只等一轮),别让后到的那一代永远排不上。
|
|
289
|
+
if [ "$waited" -eq 0 ]; then
|
|
290
|
+
log_line "→ 自愈锁正在装 ${held:-未知} 那一代,我方目标是 ${target} —— 等 ${SYNC_LOCK_WAIT_SECONDS} 秒再抢一次"
|
|
291
|
+
sleep "$SYNC_LOCK_WAIT_SECONDS"
|
|
292
|
+
waited=1
|
|
293
|
+
continue
|
|
294
|
+
fi
|
|
295
|
+
return 3
|
|
296
|
+
done
|
|
297
|
+
return 1
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
# 持锁期间的"我还在跑":每 30 秒碰一下 heartbeat。锁没了、或者已经不是自己的锁了
|
|
301
|
+
# (被判过期、别人清了重抢)就自己退出 —— 别给**别人的**锁碰心跳,也别留孤儿进程。
|
|
302
|
+
start_sync_heartbeat() {
|
|
303
|
+
(
|
|
304
|
+
while [ -d "$SYNC_LOCK" ] && [ "$(lock_field owner)" = "$SYNC_LOCK_TOKEN" ]; do
|
|
305
|
+
touch "$SYNC_LOCK/heartbeat" 2>/dev/null || exit 0
|
|
306
|
+
sleep 30
|
|
307
|
+
done
|
|
308
|
+
) &
|
|
309
|
+
SYNC_HEARTBEAT_PID=$!
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
stop_sync_heartbeat() {
|
|
313
|
+
[ -n "${SYNC_HEARTBEAT_PID:-}" ] || return 0
|
|
314
|
+
kill "$SYNC_HEARTBEAT_PID" 2>/dev/null || true
|
|
315
|
+
SYNC_HEARTBEAT_PID=""
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
# 只删**自己的**锁:锁被清过 / 换了主人时,这里必须放手(否则新持锁者的 install 被砍在半路)。
|
|
319
|
+
release_sync_lock() {
|
|
320
|
+
case "${SYNC_LOCK:-}" in
|
|
321
|
+
*/workbench/diy/.sync.lock)
|
|
322
|
+
if [ -n "${SYNC_LOCK_TOKEN:-}" ] && [ "$(lock_field owner)" = "$SYNC_LOCK_TOKEN" ]; then
|
|
323
|
+
rm -rf "$SYNC_LOCK"
|
|
324
|
+
fi
|
|
325
|
+
;;
|
|
326
|
+
esac
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
# sync:幂等、可重跑、可续。**复核过才写 ok**。
|
|
330
|
+
run_sync() {
|
|
331
|
+
# 没有基准 = 不知道谁是"正在跑的外壳":这一轮**一个字节都不动**,也不写 `.sync.json`。
|
|
332
|
+
# (`setup` 那趟就是这种情形:那时 profile 里那份还没装/还是旧的。)
|
|
333
|
+
[ -n "$SHELL_ROOT" ] || return 0
|
|
334
|
+
[ -f "$SHELL_ROOT/package.json" ] || return 0
|
|
335
|
+
# 工坊还没建:那是 skeleton 模式的活,sync 不抢(首次要下几百 MB,不能挂在这条路上)。
|
|
336
|
+
[ -f "$DIY_DIR/package.json" ] || return 0
|
|
337
|
+
|
|
338
|
+
local shell_v reason=""
|
|
339
|
+
shell_v="$(shell_version)"
|
|
340
|
+
if [ -z "$shell_v" ]; then
|
|
341
|
+
log_line "→ ⚠️ 基准 ${SHELL_ROOT} 里读不到版本,这一轮自愈没跑"
|
|
342
|
+
return 1
|
|
343
|
+
fi
|
|
344
|
+
|
|
345
|
+
# 仓库副本:三个包都是 link 进本仓的,改源码工坊当场就变 —— 天然同代。
|
|
346
|
+
if [ "$IN_REPO" = "1" ]; then
|
|
347
|
+
write_sync_state ok "$shell_v" ""
|
|
348
|
+
log_line "→ 工坊是仓库副本(link:),天然同代,写 ok(${shell_v})"
|
|
349
|
+
return 0
|
|
350
|
+
fi
|
|
351
|
+
|
|
352
|
+
if sync_backoff_active "$shell_v"; then
|
|
353
|
+
log_line "→ 同代的上一次自愈失败过,还在一小时退避窗口内 —— 这一轮跳过(原因在 ${SYNC_STATE})"
|
|
354
|
+
return 0
|
|
355
|
+
fi
|
|
356
|
+
|
|
357
|
+
local lock_rc=0
|
|
358
|
+
acquire_sync_lock "$shell_v" || lock_rc=$?
|
|
359
|
+
if [ "$lock_rc" = "2" ]; then
|
|
360
|
+
reason="建不出自愈锁(${SYNC_LOCK})—— ${DIY_DIR} 不可写?"
|
|
361
|
+
log_line "→ ⚠️ 自愈失败:${reason}"
|
|
362
|
+
write_sync_state failed "$shell_v" "$reason"
|
|
363
|
+
return 1
|
|
364
|
+
fi
|
|
365
|
+
if [ "$lock_rc" = "1" ]; then
|
|
366
|
+
log_line "→ 同一代(${shell_v})的自愈正持锁(${SYNC_LOCK})—— 这一轮跳过"
|
|
367
|
+
return 0
|
|
368
|
+
fi
|
|
369
|
+
if [ "$lock_rc" = "3" ]; then
|
|
370
|
+
log_line "→ 另一代的自愈正持锁,等过一轮仍没抢到 —— 这一轮跳过(下一轮启动再试)"
|
|
371
|
+
return 0
|
|
372
|
+
fi
|
|
373
|
+
trap 'stop_sync_heartbeat; release_sync_lock' EXIT
|
|
374
|
+
start_sync_heartbeat
|
|
375
|
+
|
|
376
|
+
write_sync_state syncing "$shell_v" ""
|
|
377
|
+
log_line "→ 自愈:把工坊对齐到基准那一代 $shell_v"
|
|
378
|
+
|
|
379
|
+
compute_specs "$shell_v"
|
|
380
|
+
|
|
381
|
+
# 重写工坊根清单:devDependency 与 overrides 都对齐到这一代。
|
|
382
|
+
# overrides 是**关键的那一半** —— 模块自己声明的 `^0.1.x` 会让 pnpm 给每个模块另装一份
|
|
383
|
+
# **源上**的包,于是同一个工坊里两代工具链并存(那场事故见下面骨架段)。
|
|
384
|
+
# 两处写的是**同一套值**:`compute_specs` 给的就是"同时能当 devDependency 与 overrides 用"的形状。
|
|
385
|
+
# ⚠️ 一份值只从一个地方取:曾经 devDependencies 取 `$shell_v`、overrides 取 `$CORE_SPEC`,
|
|
386
|
+
# 两个变量在正常路径上恰好相等,于是"它们漂开"这种病**判据抓不到**(变异测试当场露馅)。
|
|
387
|
+
# 先说一句为什么先读一遍:清单坏了(手改坏 / 写了一半)时 JSON.parse 会当场抛,
|
|
388
|
+
# 那会带着 `set -e` 直接退出去、状态停在 syncing —— 用户只看到"每次启动都重试"。
|
|
389
|
+
if ! node -e 'JSON.parse(require("node:fs").readFileSync(process.argv[1], "utf8"))' \
|
|
390
|
+
"$DIY_DIR/package.json" >>"$SEED_LOG" 2>&1; then
|
|
391
|
+
reason="工坊根清单读不了($DIY_DIR/package.json)—— 它坏了,先把它修回一份合法 JSON"
|
|
392
|
+
write_sync_state failed "$shell_v" "$reason"
|
|
393
|
+
log_line "→ ⚠️ 自愈失败:${reason}"
|
|
394
|
+
return 1
|
|
395
|
+
fi
|
|
396
|
+
node - "$DIY_DIR/package.json" "$CORE_SPEC" "$TOOLS_SPEC" "$SHARED_SPEC" <<'NODE'
|
|
397
|
+
const [file, core, tools, shared] = process.argv.slice(2);
|
|
398
|
+
const fs = require('node:fs');
|
|
399
|
+
const manifest = JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
400
|
+
const want = {
|
|
401
|
+
'@dsh-workbench/core': core,
|
|
402
|
+
'@dsh-workbench/module-tools': tools,
|
|
403
|
+
'@dsh-workbench/shared': shared,
|
|
404
|
+
};
|
|
405
|
+
manifest.devDependencies = { ...(manifest.devDependencies ?? {}), ...want };
|
|
406
|
+
manifest.pnpm = {
|
|
407
|
+
...(manifest.pnpm ?? {}),
|
|
408
|
+
overrides: { ...(manifest.pnpm?.overrides ?? {}), ...want },
|
|
409
|
+
};
|
|
410
|
+
fs.writeFileSync(file, `${JSON.stringify(manifest, null, 2)}\n`);
|
|
411
|
+
NODE
|
|
412
|
+
|
|
413
|
+
if ! command -v pnpm >/dev/null 2>&1; then
|
|
414
|
+
reason="PATH 上没有 pnpm;装好 pnpm 后重跑:cd $DIY_DIR && pnpm install"
|
|
415
|
+
write_sync_state failed "$shell_v" "$reason"
|
|
416
|
+
log_line "→ ⚠️ 自愈失败:$reason"
|
|
417
|
+
return 1
|
|
418
|
+
fi
|
|
419
|
+
|
|
420
|
+
log_line "→ 在工坊里 pnpm install(增量,用 .npmrc 里那份 store/cache)"
|
|
421
|
+
local out="" ok=1
|
|
422
|
+
out="$( (cd "$DIY_DIR" && pnpm install) 2>&1 )" || ok=0
|
|
423
|
+
# 原话照录:失败时"为什么"全在这几行里,不能让用户只看到一句"装不上"。
|
|
424
|
+
[ -z "$out" ] || printf '%s\n' "$out" >>"$SEED_LOG" 2>/dev/null || true
|
|
425
|
+
|
|
426
|
+
# 复核实装版本 —— 这一条才是判据,pnpm 的退出码不是。
|
|
427
|
+
local v_shell v_core v_tools v_shared
|
|
428
|
+
{
|
|
429
|
+
read -r v_shell || true
|
|
430
|
+
read -r v_core || true
|
|
431
|
+
read -r v_tools || true
|
|
432
|
+
read -r v_shared || true
|
|
433
|
+
} < <(read_generations)
|
|
434
|
+
|
|
435
|
+
if [ "$v_core" = "$shell_v" ] && [ "$v_tools" = "$shell_v" ] && [ "$v_shared" = "$shell_v" ]; then
|
|
436
|
+
write_sync_state ok "$shell_v" ""
|
|
437
|
+
log_line "→ 自愈完成:工坊与基准同代(${shell_v})"
|
|
438
|
+
return 0
|
|
439
|
+
fi
|
|
440
|
+
|
|
441
|
+
reason="install 之后复核仍不同代:core=${v_core:-读不到} tools=${v_tools:-读不到} shared=${v_shared:-读不到}(目标 ${shell_v})"
|
|
442
|
+
write_sync_state failed "$shell_v" "$reason"
|
|
443
|
+
[ "$ok" = "1" ] || log_line "→ pnpm install 退出码非 0(上面是它的原话)"
|
|
444
|
+
log_line "→ ⚠️ 自愈失败:$reason"
|
|
445
|
+
return 1
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
# entry 的挂钩:便宜比对一次;不一致就**后台**自愈并立刻返回(启动路径不许被拖住)。
|
|
449
|
+
#
|
|
450
|
+
# 没基准(`DSH_WB_SHELL_ROOT` 没设)→ **什么都不做**:不读工坊实装版本、不起 sync、不写状态。
|
|
451
|
+
# 读不到版本(工坊还没装好 / 首次)→ **也不做**:那是 skeleton 模式的活,
|
|
452
|
+
# 把首次安装搬进 entry 就是让第一次启动卡在几百 MB 的下载上。
|
|
453
|
+
maybe_start_workshop_sync() {
|
|
454
|
+
[ -n "$SHELL_ROOT" ] || return 0
|
|
455
|
+
[ -f "$SHELL_ROOT/package.json" ] || return 0
|
|
456
|
+
[ -f "$DIY_DIR/package.json" ] || return 0
|
|
457
|
+
|
|
458
|
+
local shell_v core_v tools_v shared_v
|
|
459
|
+
{
|
|
460
|
+
read -r shell_v || true
|
|
461
|
+
read -r core_v || true
|
|
462
|
+
read -r tools_v || true
|
|
463
|
+
read -r shared_v || true
|
|
464
|
+
} < <(read_generations)
|
|
465
|
+
|
|
466
|
+
[ -n "$shell_v" ] && [ -n "$core_v" ] && [ -n "$tools_v" ] && [ -n "$shared_v" ] || return 0
|
|
467
|
+
if [ "$core_v" = "$shell_v" ] && [ "$tools_v" = "$shell_v" ] && [ "$shared_v" = "$shell_v" ]; then
|
|
468
|
+
return 0
|
|
469
|
+
fi
|
|
470
|
+
if sync_backoff_active "$shell_v"; then
|
|
471
|
+
return 0
|
|
472
|
+
fi
|
|
473
|
+
|
|
474
|
+
mkdir -p "$(dirname "$SEED_LOG")" 2>/dev/null || true
|
|
475
|
+
log_line "→ 工坊与基准不同代(实装 core=$core_v tools=$tools_v shared=${shared_v};基准 ${SHELL_ROOT} = ${shell_v})—— 后台自愈"
|
|
476
|
+
# 两个 fd 都重定向进 seed.log:一来日志只有一处,二来后台子进程**不拖住调用者的管道**
|
|
477
|
+
# (不然 execFileSync 那类调用会一直等它,entry 看起来就像卡住了)。
|
|
478
|
+
# 基准要**显式传下去**:子进程是另一个 shell,不能指望环境里那份一定还在。
|
|
479
|
+
DSH_HOME="$DSH_HOME" DSH_WB_SHELL_ROOT="$SHELL_ROOT" nohup bash "$SELF" sync >>"$SEED_LOG" 2>&1 &
|
|
480
|
+
return 0
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
# sync 可以手敲,也是 entry 挂钩拉起来的那条路。
|
|
484
|
+
if [ "$MODE" = "sync" ]; then
|
|
485
|
+
run_sync || exit $?
|
|
486
|
+
exit 0
|
|
487
|
+
fi
|
|
488
|
+
|
|
46
489
|
# ── 稳定入口:$DSH_HOME/workbench/bin/dsh-wb-module ──────────────────────────
|
|
47
490
|
#
|
|
48
491
|
# 为什么要它:指引让 agent 写 `pnpm exec dsh-wb-module …`(到处都对,但它依赖 cwd 在工坊里、
|
|
@@ -69,6 +512,9 @@ chmod +x "$ENTRY_DIR/dsh-wb-module"
|
|
|
69
512
|
|
|
70
513
|
if [ "$MODE" = "entry" ]; then
|
|
71
514
|
echo "→ DIY 工坊入口:$ENTRY_DIR/dsh-wb-module"
|
|
515
|
+
# 挂指引**不**看基准(每次启动都跑一遍,幂等):它是"锦上添花",不是代际决策。
|
|
516
|
+
link_workshop_skills
|
|
517
|
+
maybe_start_workshop_sync
|
|
72
518
|
exit 0
|
|
73
519
|
fi
|
|
74
520
|
|
|
@@ -85,19 +531,9 @@ fi
|
|
|
85
531
|
# 才不会静默跳过 —— 跳过等于没验)。
|
|
86
532
|
#
|
|
87
533
|
# 两个身份同 bin/dsh-workbench:**仓库副本**把三个 @dsh-workbench/* 用 link 指进本仓
|
|
88
|
-
#
|
|
89
|
-
DIY_DIR="$DSH_HOME/workbench/diy"
|
|
534
|
+
# (改工具链立刻生效),**安装包副本**从源上按版本装。判据与值都归上面的 `compute_specs`。
|
|
90
535
|
mkdir -p "$DIY_DIR/packages"
|
|
91
|
-
|
|
92
|
-
if [ "$IN_REPO" = "1" ]; then
|
|
93
|
-
CORE_SPEC="link:$REPO_DIR"
|
|
94
|
-
TOOLS_SPEC="link:$(cd "$REPO_DIR/../module-tools" && pwd)"
|
|
95
|
-
SHARED_SPEC="link:$(cd "$REPO_DIR/../shared" && pwd)"
|
|
96
|
-
else
|
|
97
|
-
CORE_SPEC="@dsh-workbench/core@^$(node -p "require('$REPO_DIR/package.json').version" 2>/dev/null || echo 0)"
|
|
98
|
-
TOOLS_SPEC="@dsh-workbench/module-tools@^$(node -p "require('$REPO_DIR/package.json').version" 2>/dev/null || echo 0)"
|
|
99
|
-
SHARED_SPEC="@dsh-workbench/shared@^$(node -p "require('$REPO_DIR/package.json').version" 2>/dev/null || echo 0)"
|
|
100
|
-
fi
|
|
536
|
+
compute_specs
|
|
101
537
|
|
|
102
538
|
# 工坊必须**钉住自己那一代 pnpm**(与仓库根钉的同一个)。
|
|
103
539
|
#
|
|
@@ -214,3 +650,15 @@ else
|
|
|
214
650
|
echo "→ 装工坊依赖(第一次会下载 react / typescript / vitest,几百 MB,之后不再下)"
|
|
215
651
|
(cd "$DIY_DIR" && pnpm install 2>&1 | tail -6)
|
|
216
652
|
fi
|
|
653
|
+
|
|
654
|
+
# 完整的这一趟跑完:先挂指引(不管有没有基准 —— setup 那趟也要建起来),再看要不要自愈。
|
|
655
|
+
link_workshop_skills
|
|
656
|
+
|
|
657
|
+
# 完整的这一趟跑完,也顺手比一次代际 —— **有基准才算**。
|
|
658
|
+
#
|
|
659
|
+
# 这一趟有两个调用者:启动器(它 export 了 `DSH_WB_SHELL_ROOT` = profile 里那份)与
|
|
660
|
+
# `setup`(`seed-profile.sh`,**不设**基准)。前者比一下有意义:重跑 setup/自愈之后工坊当场
|
|
661
|
+
# 就跟上正在跑的那一代,不必等下一次启动被 entry 兜。后者在这一行**空转**(没基准 = 不动)——
|
|
662
|
+
# 那正是要的:setup 时 profile 里那份还没装/还是旧的,拿它当基准就是又一次"谁后跑谁说了算"。
|
|
663
|
+
# 不拖住收尾:`maybe_start_workshop_sync` 只做比对 + `nohup` 后台 spawn,立刻返回。
|
|
664
|
+
maybe_start_workshop_sync
|
|
@@ -21,7 +21,7 @@
|
|
|
21
21
|
## 1 落点:工坊
|
|
22
22
|
|
|
23
23
|
```
|
|
24
|
-
$DSH_HOME/workbench/diy/ ← 工坊(一个 pnpm workspace
|
|
24
|
+
$DSH_HOME/workbench/diy/ ← 工坊(一个 pnpm workspace,依赖已经装好、与外壳同代)
|
|
25
25
|
package.json pnpm-workspace.yaml .npmrc node_modules/
|
|
26
26
|
packages/
|
|
27
27
|
module-<unit>/ ← 你要造的模块住这儿
|
|
@@ -31,6 +31,11 @@ $DSH_HOME/workbench/diy/ ← 工坊(一个 pnpm workspace,依赖
|
|
|
31
31
|
|
|
32
32
|
⚠️ **骨架不在就先补上**:启动器会自愈(少了才建),没建起来时跑一次 `dsh-workbench setup`(幂等)
|
|
33
33
|
再回来跑模块命令 —— 没有骨架时门禁会把"模块不在任何 workspace 里"讲成"声明写错了",那是症状。
|
|
34
|
+
工具链跟外壳**不同代**时不用你管:**每一次工作台启动**都会拿"正在跑的那份外壳"(基准经
|
|
35
|
+
`DSH_WB_SHELL_ROOT` 显式传入)比一次,不同代就在后台把它同步到同代 —— 所以升级之后
|
|
36
|
+
**下一次启动**就对上了(`workbench_diy_status` 里能看到「⏳ 工坊工具链正在后台同步到 <版本>」,
|
|
37
|
+
或同步没成时的「⚠️ 工坊工具链还是上一代(…):<原因>」加一条修法;同步那几行在
|
|
38
|
+
`$DSH_HOME/workbench/seed.log`)。
|
|
34
39
|
|
|
35
40
|
工坊里的 `@dsh-workbench/*` **只有一份**(工坊根的 `link:` + 一份 `pnpm.overrides` 钉着):模块自己的
|
|
36
41
|
`pnpm build` / `pnpm exec dsh-wb-module` 和工坊根的 `.bin/dsh-wb-module` 是同一个二进制,
|