@cocorograph/hub-agent 0.8.18 → 0.8.20
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/package.json +1 -1
- package/src/claude-md-sync.mjs +704 -57
- package/src/claude-md.mjs +12 -8
- package/src/main.mjs +27 -10
- package/src/pane-cli-flag.mjs +30 -0
- package/src/pointer-context.mjs +196 -0
- package/src/state.mjs +39 -195
- package/src/tmux.mjs +6 -94
package/src/claude-md-sync.mjs
CHANGED
|
@@ -18,7 +18,10 @@
|
|
|
18
18
|
*
|
|
19
19
|
* 関連: backend Stage 1 / 1.5
|
|
20
20
|
* GET /api/admin/project/claude-md-by-dir/?dir_name=...
|
|
21
|
-
* PUT /api/admin/project/claude-md-by-dir/?dir_name=... body: { claude_md }
|
|
21
|
+
* PUT /api/admin/project/claude-md-by-dir/?dir_name=... body: { claude_md, expected_hash? }
|
|
22
|
+
* expected_hash は楽観ロック。GET で読んだ hash を必ず添えて送る(不一致なら 409)。
|
|
23
|
+
* CLI 種別を取り違えた本文(CLAUDE.md 正本へ AGENTS.md の内容 等)は 400
|
|
24
|
+
* `config_md_kind_mismatch` で拒否される。再送しても通らないので理由を分ける。
|
|
22
25
|
*/
|
|
23
26
|
import fs from "node:fs/promises"
|
|
24
27
|
import os from "node:os"
|
|
@@ -70,24 +73,151 @@ function makeBakName(prefix) {
|
|
|
70
73
|
return `${prefix}.${ts}.bak`
|
|
71
74
|
}
|
|
72
75
|
|
|
76
|
+
/**
|
|
77
|
+
* `makeBakName` が生成するタイムスタンプ部分の形。
|
|
78
|
+
*
|
|
79
|
+
* `new Date().toISOString()` は `YYYY-MM-DDTHH:mm:ss.sssZ` 固定で、`makeBakName` が
|
|
80
|
+
* `:` と `.` を `-` に置換するため `2026-08-01T12-34-56-789Z` になる。
|
|
81
|
+
* ⚠️ makeBakName を変えたらこの定数も必ず揃えること(`_internals` 経由の
|
|
82
|
+
* 往復テストが両者の食い違いを検知する)。
|
|
83
|
+
*/
|
|
84
|
+
const BAK_TS_PATTERN = "\\d{4}-\\d{2}-\\d{2}T\\d{2}-\\d{2}-\\d{2}-\\d{3}Z"
|
|
85
|
+
|
|
86
|
+
function escapeRegExp(s) {
|
|
87
|
+
return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* 「hub-agent が `makeBakName(prefix)` で作った .bak」だけに一致する正規表現を返す。
|
|
92
|
+
*
|
|
93
|
+
* FIFO 削除の対象集合をこれで絞る。単純な `startsWith(prefix + ".")` だと
|
|
94
|
+
* kind='normal'(prefix = `CLAUDE.md`)の集合に `CLAUDE.md.conflict.<ts>.bak` や
|
|
95
|
+
* 手作業で置かれた異物(`CLAUDE.md.contaminated.<数値>.bak` 等)まで入り込み、
|
|
96
|
+
* 名前降順では conflict が先頭に並ぶため **健全な通常 bak が保持枠から押し出されて
|
|
97
|
+
* 削除される**(実際に「手元から復元できない」事故の直接原因になった)。
|
|
98
|
+
*
|
|
99
|
+
* `.includes('.conflict.')` を弾くだけの対処にしないのは、将来別の名前が増えたときに
|
|
100
|
+
* 再び巻き添えになるため。「自分が作った形と一致するものだけ」を対象にする。
|
|
101
|
+
*/
|
|
102
|
+
function bakNamePattern(prefix) {
|
|
103
|
+
return new RegExp(`^${escapeRegExp(prefix)}\\.${BAK_TS_PATTERN}\\.bak$`)
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Hub API **1 往復あたり**(headers 受信 〜 body 消費)に掛ける既定の期限 (ms)。
|
|
108
|
+
*
|
|
109
|
+
* ⚠️ **このモジュールが保証するのは「各 HTTP 往復が 15 秒で打ち切られる」ことだけ**。
|
|
110
|
+
* 1 回の同期は GET と PUT を行うし、ファイル I/O と呼び出し元の処理も乗る。さらに
|
|
111
|
+
* 初期化経路(`src/claude-md.mjs` の `ensureClaudeMd`)にはまだ期限が無い。
|
|
112
|
+
* つまり **同期全体・セッション起動全体の budget はここでは決まらない**(別タスク)。
|
|
113
|
+
* ここに「全体で N 秒以内」と書くと、その瞬間から嘘になる。
|
|
114
|
+
*
|
|
115
|
+
* 15 秒とした根拠: 短くしたい誘惑があるが、低速回線・スリープ復帰直後の**正常な**
|
|
116
|
+
* 往復を恒常的に失敗させる方が害が大きい(同期が毎回諦めると正本がドリフトし続ける)。
|
|
117
|
+
* 実際の往復は本文 1 KB 未満・1 秒以内で完了するため 15 秒は十分な余裕がある。
|
|
118
|
+
* ⚠️ 変更前は **無制限**(body を閉じないレスポンスで往復が永久に返らない)だったので、
|
|
119
|
+
* 15 秒でも厳密な改善である。
|
|
120
|
+
*/
|
|
121
|
+
const HUB_REQUEST_TIMEOUT_MS = 15_000
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* 1 往復ぶんの期限とキャンセル手段をまとめたもの。
|
|
125
|
+
*
|
|
126
|
+
* ⚠️ `Promise.race` だけでは**負けた側(`fetch` / `res.json()` / body 読み)が裏で
|
|
127
|
+
* 走り続ける**ため、期限超過時は必ず `AbortController.abort()`(+ 呼び出し側での
|
|
128
|
+
* `reader.cancel()`)を行い、正常終了時は `clear()` でタイマーを解除すること。
|
|
129
|
+
* タイマーは `unref()` して、これだけでプロセスが生き残らないようにする。
|
|
130
|
+
*/
|
|
131
|
+
function createDeadline(ms) {
|
|
132
|
+
const controller = new AbortController()
|
|
133
|
+
let fire = null
|
|
134
|
+
const expired = new Promise((resolve) => {
|
|
135
|
+
fire = resolve
|
|
136
|
+
})
|
|
137
|
+
const timer = setTimeout(() => {
|
|
138
|
+
try {
|
|
139
|
+
controller.abort()
|
|
140
|
+
} catch {
|
|
141
|
+
/* noop */
|
|
142
|
+
}
|
|
143
|
+
fire()
|
|
144
|
+
}, ms)
|
|
145
|
+
if (typeof timer.unref === "function") timer.unref()
|
|
146
|
+
return {
|
|
147
|
+
signal: controller.signal,
|
|
148
|
+
abort() {
|
|
149
|
+
try {
|
|
150
|
+
controller.abort()
|
|
151
|
+
} catch {
|
|
152
|
+
/* noop */
|
|
153
|
+
}
|
|
154
|
+
},
|
|
155
|
+
clear() {
|
|
156
|
+
clearTimeout(timer)
|
|
157
|
+
},
|
|
158
|
+
/**
|
|
159
|
+
* `promise` を期限付きで待つ。
|
|
160
|
+
* @returns {Promise<{timedOut: boolean, value?: any, error?: any}>}
|
|
161
|
+
* 期限超過なら `{timedOut:true}`。負けた promise には必ずハンドラを付けて
|
|
162
|
+
* unhandled rejection にしない。
|
|
163
|
+
*/
|
|
164
|
+
async guard(promise) {
|
|
165
|
+
return await Promise.race([
|
|
166
|
+
promise.then(
|
|
167
|
+
(value) => ({ timedOut: false, value }),
|
|
168
|
+
(error) => ({ timedOut: false, error }),
|
|
169
|
+
),
|
|
170
|
+
expired.then(() => ({ timedOut: true })),
|
|
171
|
+
])
|
|
172
|
+
},
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
73
176
|
/**
|
|
74
177
|
* Hub から claude-md-by-dir で正本を取得する。
|
|
75
178
|
*
|
|
76
179
|
* - 404 = 該当 Project 無し(Domain 型・local_dir 型 workspace)→ null
|
|
77
180
|
* - その他のエラー → null(warn ログ)
|
|
78
181
|
*
|
|
182
|
+
* ⚠️ **GET は PUT より先に走る**ので、ここに期限が無いと PUT 側だけ直しても
|
|
183
|
+
* 「セッション作成が止まらない」という目的を達成できない(同じ失敗の型)。
|
|
184
|
+
* 非 OK 時の本文は読まない(status だけで十分)。掛けているのは期限だけ。
|
|
185
|
+
*
|
|
79
186
|
* @returns {Promise<null | {project_id, claude_md, claude_md_hash, claude_md_updated_at}>}
|
|
80
187
|
*/
|
|
81
|
-
async function fetchHubClaudeMd({
|
|
188
|
+
async function fetchHubClaudeMd({
|
|
189
|
+
hubUrl,
|
|
190
|
+
accessToken,
|
|
191
|
+
dirName,
|
|
192
|
+
fetchImpl,
|
|
193
|
+
logger,
|
|
194
|
+
timeoutMs = HUB_REQUEST_TIMEOUT_MS,
|
|
195
|
+
}) {
|
|
82
196
|
const f = fetchImpl || globalThis.fetch
|
|
83
197
|
if (!f) return null
|
|
84
198
|
const url =
|
|
85
199
|
`${hubUrl.replace(/\/+$/, "")}/api/admin/project/claude-md-by-dir/` +
|
|
86
200
|
`?dir_name=${encodeURIComponent(dirName)}`
|
|
201
|
+
const deadline = createDeadline(timeoutMs)
|
|
87
202
|
try {
|
|
88
|
-
const
|
|
89
|
-
|
|
90
|
-
|
|
203
|
+
const fetched = await deadline.guard(
|
|
204
|
+
// 同期的に throw する fetch 実装でも guard に載るよう then で包む。
|
|
205
|
+
Promise.resolve().then(() =>
|
|
206
|
+
f(url, {
|
|
207
|
+
headers: { Authorization: `Bearer ${accessToken}` },
|
|
208
|
+
signal: deadline.signal,
|
|
209
|
+
}),
|
|
210
|
+
),
|
|
211
|
+
)
|
|
212
|
+
if (fetched.timedOut) {
|
|
213
|
+
logger?.warn?.(
|
|
214
|
+
{ dirName, timeout_ms: timeoutMs },
|
|
215
|
+
"fetchHubClaudeMd: round-trip timed out; giving up (best-effort sync)",
|
|
216
|
+
)
|
|
217
|
+
return null
|
|
218
|
+
}
|
|
219
|
+
if (fetched.error) throw fetched.error
|
|
220
|
+
const res = fetched.value
|
|
91
221
|
if (res.status === 404) return null
|
|
92
222
|
if (!res.ok) {
|
|
93
223
|
logger?.warn?.(
|
|
@@ -96,53 +226,325 @@ async function fetchHubClaudeMd({ hubUrl, accessToken, dirName, fetchImpl, logge
|
|
|
96
226
|
)
|
|
97
227
|
return null
|
|
98
228
|
}
|
|
99
|
-
|
|
229
|
+
// body 消費にも同じ期限を掛ける(headers だけ返して body を閉じない応答対策)。
|
|
230
|
+
const parsed = await deadline.guard(Promise.resolve().then(() => res.json()))
|
|
231
|
+
if (parsed.timedOut) {
|
|
232
|
+
deadline.abort()
|
|
233
|
+
logger?.warn?.(
|
|
234
|
+
{ dirName, timeout_ms: timeoutMs },
|
|
235
|
+
"fetchHubClaudeMd: response body read timed out",
|
|
236
|
+
)
|
|
237
|
+
return null
|
|
238
|
+
}
|
|
239
|
+
if (parsed.error) throw parsed.error
|
|
240
|
+
return parsed.value
|
|
100
241
|
} catch (err) {
|
|
101
242
|
logger?.warn?.(
|
|
102
243
|
{ dirName, err: err?.message || String(err) },
|
|
103
244
|
"fetchHubClaudeMd: request failed",
|
|
104
245
|
)
|
|
105
246
|
return null
|
|
247
|
+
} finally {
|
|
248
|
+
deadline.clear()
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/**
|
|
253
|
+
* 非 2xx レスポンスから拒否理由を取り出すときの allowlist と長さ上限。
|
|
254
|
+
*
|
|
255
|
+
* backend は「AGENTS.md の内容を CLAUDE.md 正本へ保存しようとした」PUT を 400 +
|
|
256
|
+
* `{"error":"config_md_kind_mismatch", "detail", "expected_kind", "detected_heading"}`
|
|
257
|
+
* で拒否する(2026-08-01 本番投入)。本文を読まないとこの拒否理由が誰にも見えず、
|
|
258
|
+
* 「同期が静かに止まる」状態になるため、非 OK のときだけ本文を読む。
|
|
259
|
+
*
|
|
260
|
+
* ただしレスポンス本文は **JSON とは限らない**(nginx / Cloudflare の HTML エラー
|
|
261
|
+
* ページが返ることがある)し、巨大だったり機密値を含みうる。そのため:
|
|
262
|
+
* - 載せるのは **allowlist したキーだけ**(本文を丸ごとログに出さない)
|
|
263
|
+
* - 各値に長さ上限を掛け、切り捨てたことが分かる形にする
|
|
264
|
+
* - JSON として読めない / allowlist キーが 1 つも無い場合は **本文を出さず**、
|
|
265
|
+
* status・content-type・本文長だけにする
|
|
266
|
+
*/
|
|
267
|
+
const PUT_ERROR_FIELD_ALLOWLIST = ["error", "detail", "expected_kind", "detected_heading"]
|
|
268
|
+
const PUT_ERROR_FIELD_MAX_CHARS = 200
|
|
269
|
+
|
|
270
|
+
/**
|
|
271
|
+
* 非 2xx 本文の **受信上限(UTF-8 バイト)**。
|
|
272
|
+
*
|
|
273
|
+
* ⚠️ これは「parse の上限」ではなく「メモリに載せる上限」である。`res.text()` は
|
|
274
|
+
* 全文をメモリへ読み込んでからでないと長さが分からないため、100 MB の chunked body
|
|
275
|
+
* では 100 MB を抱えた後にしか気づけない。そこで `res.body` を**逐次読み**し、
|
|
276
|
+
* バイト数がこの上限を超えた時点で読み取りを打ち切って reader を cancel する。
|
|
277
|
+
*
|
|
278
|
+
* 64 KiB とした根拠: 実際の拒否応答(`config_md_kind_mismatch` の JSON)は 1 KB 未満、
|
|
279
|
+
* nginx / Cloudflare の HTML エラーページでも数 KB で、64 KiB あれば正当な診断情報は
|
|
280
|
+
* 必ず収まる。
|
|
281
|
+
*/
|
|
282
|
+
const PUT_ERROR_BODY_MAX_BYTES = 64 * 1024
|
|
283
|
+
|
|
284
|
+
/** ログ・戻り値に載せる文字列に長さ上限を掛ける(切り捨てたことが分かる形にする)。 */
|
|
285
|
+
function truncateForLog(value, max = PUT_ERROR_FIELD_MAX_CHARS) {
|
|
286
|
+
const s = String(value)
|
|
287
|
+
if (s.length <= max) return s
|
|
288
|
+
return `${s.slice(0, max)}…[truncated ${s.length} chars]`
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
/**
|
|
292
|
+
* パース済みレスポンスから allowlist キーだけを取り出す。
|
|
293
|
+
*
|
|
294
|
+
* 値は文字列・数値・真偽値のみ採用する。オブジェクト / 配列は入れ子に何が入るか
|
|
295
|
+
* 保証できないので載せない。1 つも取れなければ null(=呼び出し側は本文を出さない)。
|
|
296
|
+
*/
|
|
297
|
+
function pickAllowedErrorFields(parsed) {
|
|
298
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return null
|
|
299
|
+
const out = {}
|
|
300
|
+
for (const key of PUT_ERROR_FIELD_ALLOWLIST) {
|
|
301
|
+
const v = parsed[key]
|
|
302
|
+
if (typeof v === "string") out[key] = truncateForLog(v)
|
|
303
|
+
else if (typeof v === "number" || typeof v === "boolean") out[key] = String(v)
|
|
106
304
|
}
|
|
305
|
+
return Object.keys(out).length > 0 ? out : null
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
/**
|
|
309
|
+
* `res.body`(ReadableStream)をバイト上限付きで読む。
|
|
310
|
+
*
|
|
311
|
+
* - 上限を超えた時点で読み取りを打ち切る(`tooLarge = true`)。
|
|
312
|
+
* - ⚠️ 打ち切った断片は **JSON として解釈しない**(呼び出し側で parse を抑止する)。
|
|
313
|
+
* 壊れた JSON を読ませると、無い値を「取れた」と誤認しかねない。
|
|
314
|
+
* - ⚠️ `TextDecoder` は `{stream: true}` で回す。chunk 境界がマルチバイト文字の
|
|
315
|
+
* 途中に来ても壊れないようにするため。
|
|
316
|
+
*/
|
|
317
|
+
async function readStreamWithByteCap(reader, maxBytes) {
|
|
318
|
+
const decoder = new TextDecoder("utf-8")
|
|
319
|
+
let text = ""
|
|
320
|
+
let bytes = 0
|
|
321
|
+
let tooLarge = false
|
|
322
|
+
try {
|
|
323
|
+
for (;;) {
|
|
324
|
+
const { done, value } = await reader.read()
|
|
325
|
+
if (done) break
|
|
326
|
+
const size = value?.byteLength ?? value?.length ?? 0
|
|
327
|
+
if (bytes + size > maxBytes) {
|
|
328
|
+
tooLarge = true
|
|
329
|
+
break
|
|
330
|
+
}
|
|
331
|
+
bytes += size
|
|
332
|
+
text += decoder.decode(value, { stream: true })
|
|
333
|
+
}
|
|
334
|
+
if (!tooLarge) text += decoder.decode() // 末尾の保留バイトを flush
|
|
335
|
+
} finally {
|
|
336
|
+
// 途中で抜けた場合に接続を解放する(上限超過・例外のどちらでも)。
|
|
337
|
+
try {
|
|
338
|
+
reader.cancel()?.catch?.(() => {})
|
|
339
|
+
} catch {
|
|
340
|
+
/* noop */
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
return { text, bytes, tooLarge }
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
/**
|
|
347
|
+
* 非 OK レスポンスの診断情報を安全に読み出す。
|
|
348
|
+
*
|
|
349
|
+
* 期限(`deadline`)とバイト上限の両方を掛ける。`res.body` を持たない実装
|
|
350
|
+
* (簡易スタブ / 別実装)では **例外を投げず** status だけで諦める(同期を壊さない)。
|
|
351
|
+
*
|
|
352
|
+
* @returns {Promise<{fields: object|null, contentType: string|null,
|
|
353
|
+
* declaredLength: number|null, bodyBytes: number|null,
|
|
354
|
+
* bodyTooLarge: boolean, timedOut: boolean}>}
|
|
355
|
+
*/
|
|
356
|
+
async function readNonOkDiagnostics(res, deadline) {
|
|
357
|
+
const header = (name) =>
|
|
358
|
+
typeof res?.headers?.get === "function" ? res.headers.get(name) : null
|
|
359
|
+
const rawType = header("content-type")
|
|
360
|
+
const contentType = rawType ? truncateForLog(rawType, 120) : null
|
|
361
|
+
// ⚠️ Number(null) は 0 になる。ヘッダー欠落を「0 バイトと申告された」と読むと
|
|
362
|
+
// ログが嘘をつくので、欠落・空文字は「申告なし (null)」として扱う。
|
|
363
|
+
const declaredRaw = header("content-length")
|
|
364
|
+
const declaredNum =
|
|
365
|
+
declaredRaw === null || declaredRaw === undefined || declaredRaw === ""
|
|
366
|
+
? Number.NaN
|
|
367
|
+
: Number(declaredRaw)
|
|
368
|
+
const declaredLength =
|
|
369
|
+
Number.isFinite(declaredNum) && declaredNum >= 0 ? declaredNum : null
|
|
370
|
+
const base = {
|
|
371
|
+
fields: null,
|
|
372
|
+
contentType,
|
|
373
|
+
declaredLength,
|
|
374
|
+
bodyBytes: null,
|
|
375
|
+
bodyTooLarge: false,
|
|
376
|
+
timedOut: false,
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
// Content-Length による早期拒否。⚠️ 欠落・虚偽がありうるので、これに頼らず
|
|
380
|
+
// stream 側のバイト上限も必ず掛ける(下の readStreamWithByteCap)。
|
|
381
|
+
if (declaredLength !== null && declaredLength > PUT_ERROR_BODY_MAX_BYTES) {
|
|
382
|
+
deadline?.abort()
|
|
383
|
+
return { ...base, bodyTooLarge: true }
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
const body = res?.body
|
|
387
|
+
if (!body || typeof body.getReader !== "function") return base
|
|
388
|
+
let reader = null
|
|
389
|
+
try {
|
|
390
|
+
reader = body.getReader()
|
|
391
|
+
} catch {
|
|
392
|
+
return base
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
const read = readStreamWithByteCap(reader, PUT_ERROR_BODY_MAX_BYTES)
|
|
396
|
+
// deadline 無しでも guard と同じ形({timedOut, value?, error?})に揃える。
|
|
397
|
+
// 形が揃っていないと、読み取りが reject したときに value を分解して落ちる。
|
|
398
|
+
const guarded = deadline
|
|
399
|
+
? await deadline.guard(read)
|
|
400
|
+
: await read.then(
|
|
401
|
+
(value) => ({ timedOut: false, value }),
|
|
402
|
+
(error) => ({ timedOut: false, error }),
|
|
403
|
+
)
|
|
404
|
+
if (guarded.timedOut) {
|
|
405
|
+
// ⚠️ race で勝っただけでは読み取りが止まらない。cancel + abort まで行う。
|
|
406
|
+
try {
|
|
407
|
+
reader.cancel()?.catch?.(() => {})
|
|
408
|
+
} catch {
|
|
409
|
+
/* noop */
|
|
410
|
+
}
|
|
411
|
+
deadline?.abort()
|
|
412
|
+
return { ...base, timedOut: true }
|
|
413
|
+
}
|
|
414
|
+
if (guarded.error || !guarded.value) return base
|
|
415
|
+
|
|
416
|
+
const { text, bytes, tooLarge } = guarded.value
|
|
417
|
+
let fields = null
|
|
418
|
+
if (!tooLarge && typeof text === "string" && text.length > 0) {
|
|
419
|
+
try {
|
|
420
|
+
fields = pickAllowedErrorFields(JSON.parse(text))
|
|
421
|
+
} catch {
|
|
422
|
+
fields = null // HTML エラーページ等。本文は出さない。
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
return { ...base, fields, bodyBytes: bytes, bodyTooLarge: tooLarge }
|
|
107
426
|
}
|
|
108
427
|
|
|
109
428
|
/**
|
|
110
429
|
* Hub に PUT で本文を上書きする。Hub 側は履歴を積んでから保存する。
|
|
111
430
|
*
|
|
112
|
-
*
|
|
431
|
+
* `expectedHash` を渡すと backend が行ロック下で現在 hash と照合し、不一致なら
|
|
432
|
+
* **409** を返す(楽観ロック)。「GET してから PUT するまでに他端末 / Hub UI が
|
|
433
|
+
* 動いた」ケースを検出するために必ず渡すこと。渡さないと素通りで盲目的に上書きする。
|
|
434
|
+
*
|
|
435
|
+
* 戻り値は「通信できたか」「HTTP ステータス」「レスポンス本文」「拒否理由」を分けて返す。
|
|
436
|
+
* 呼び出し側は 409(=読んだ後に Hub が動いた。次回 sync で再判定すべき)・
|
|
437
|
+
* 400 config_md_kind_mismatch(=同じ本文を再送しても永久に通らない)・
|
|
438
|
+
* 通信失敗(=そもそも届いていない)を区別する必要があり、真偽値では表現できない。
|
|
439
|
+
* また `claude_md_updated_at` をメタへ記録するためにレスポンス本文も要る。
|
|
440
|
+
*
|
|
441
|
+
* @returns {Promise<{ok: boolean, status: number|null, data: object|null,
|
|
442
|
+
* error: object|null}>}
|
|
443
|
+
* status は通信自体が失敗した場合のみ null。
|
|
444
|
+
* error は非 OK 時の allowlist 済み拒否理由(取れなければ null)。
|
|
113
445
|
*/
|
|
114
|
-
async function putHubClaudeMd({
|
|
446
|
+
async function putHubClaudeMd({
|
|
447
|
+
hubUrl,
|
|
448
|
+
accessToken,
|
|
449
|
+
dirName,
|
|
450
|
+
body,
|
|
451
|
+
expectedHash,
|
|
452
|
+
fetchImpl,
|
|
453
|
+
logger,
|
|
454
|
+
timeoutMs = HUB_REQUEST_TIMEOUT_MS,
|
|
455
|
+
}) {
|
|
115
456
|
const f = fetchImpl || globalThis.fetch
|
|
116
|
-
if (!f) return false
|
|
457
|
+
if (!f) return { ok: false, status: null, data: null, error: null }
|
|
117
458
|
const url =
|
|
118
459
|
`${hubUrl.replace(/\/+$/, "")}/api/admin/project/claude-md-by-dir/` +
|
|
119
460
|
`?dir_name=${encodeURIComponent(dirName)}`
|
|
461
|
+
// expected_hash は「値があるときだけ」載せる。undefined を JSON 化すると
|
|
462
|
+
// キーごと消えるとはいえ、空文字を送ると backend 側で「空 hash と一致するか」の
|
|
463
|
+
// 照合に落ちるため、truthy のときのみ明示的に付与する。
|
|
464
|
+
const payload = expectedHash
|
|
465
|
+
? { claude_md: body, expected_hash: expectedHash }
|
|
466
|
+
: { claude_md: body }
|
|
467
|
+
// headers 受信から body 消費までを 1 本の期限に収める(別々に張ると、
|
|
468
|
+
// headers だけ返して body を閉じない応答で結局止まる)。
|
|
469
|
+
const deadline = createDeadline(timeoutMs)
|
|
120
470
|
try {
|
|
121
|
-
const
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
471
|
+
const fetched = await deadline.guard(
|
|
472
|
+
Promise.resolve().then(() =>
|
|
473
|
+
f(url, {
|
|
474
|
+
method: "PUT",
|
|
475
|
+
headers: {
|
|
476
|
+
Authorization: `Bearer ${accessToken}`,
|
|
477
|
+
"Content-Type": "application/json",
|
|
478
|
+
},
|
|
479
|
+
body: JSON.stringify(payload),
|
|
480
|
+
signal: deadline.signal,
|
|
481
|
+
}),
|
|
482
|
+
),
|
|
483
|
+
)
|
|
484
|
+
if (fetched.timedOut) {
|
|
485
|
+
logger?.warn?.(
|
|
486
|
+
{ dirName, timeout_ms: timeoutMs },
|
|
487
|
+
"putHubClaudeMd: round-trip timed out; giving up (best-effort sync)",
|
|
488
|
+
)
|
|
489
|
+
return { ok: false, status: null, data: null, error: null }
|
|
490
|
+
}
|
|
491
|
+
if (fetched.error) throw fetched.error
|
|
492
|
+
const res = fetched.value
|
|
129
493
|
if (!res.ok) {
|
|
494
|
+
const diag = await readNonOkDiagnostics(res, deadline)
|
|
130
495
|
logger?.warn?.(
|
|
131
|
-
{
|
|
496
|
+
{
|
|
497
|
+
dirName,
|
|
498
|
+
status: res.status,
|
|
499
|
+
content_type: diag.contentType,
|
|
500
|
+
body_bytes: diag.bodyBytes,
|
|
501
|
+
...(diag.declaredLength !== null ? { declared_length: diag.declaredLength } : {}),
|
|
502
|
+
// 完全長は分からないが「上限より大きい」ことは分かる、という記録。
|
|
503
|
+
...(diag.bodyTooLarge ? { body_too_large: true } : {}),
|
|
504
|
+
...(diag.timedOut ? { body_read_timed_out: true } : {}),
|
|
505
|
+
// allowlist で絞った拒否理由のみ。取れなければ本文は一切出さない。
|
|
506
|
+
...(diag.fields || {}),
|
|
507
|
+
},
|
|
132
508
|
"putHubClaudeMd: non-OK response",
|
|
133
509
|
)
|
|
134
|
-
return false
|
|
510
|
+
return { ok: false, status: res.status ?? null, data: null, error: diag.fields }
|
|
135
511
|
}
|
|
136
|
-
|
|
512
|
+
// 成功時の本文読み取りにも同じ期限を掛ける。読めなければ data=null のまま返し、
|
|
513
|
+
// 呼び出し側が「updated_at が取れない」として skip する(メタは進めない)。
|
|
514
|
+
const parsed = await deadline.guard(Promise.resolve().then(() => res.json()))
|
|
515
|
+
let data = null
|
|
516
|
+
if (parsed.timedOut) {
|
|
517
|
+
deadline.abort()
|
|
518
|
+
logger?.warn?.(
|
|
519
|
+
{ dirName, timeout_ms: timeoutMs },
|
|
520
|
+
"putHubClaudeMd: response body read timed out",
|
|
521
|
+
)
|
|
522
|
+
} else if (!parsed.error) {
|
|
523
|
+
data = parsed.value // 本文が読めなくても PUT 自体は成功している
|
|
524
|
+
}
|
|
525
|
+
return { ok: true, status: res.status ?? 200, data, error: null }
|
|
137
526
|
} catch (err) {
|
|
138
527
|
logger?.warn?.(
|
|
139
528
|
{ dirName, err: err?.message || String(err) },
|
|
140
529
|
"putHubClaudeMd: request failed",
|
|
141
530
|
)
|
|
142
|
-
return false
|
|
531
|
+
return { ok: false, status: null, data: null, error: null }
|
|
532
|
+
} finally {
|
|
533
|
+
deadline.clear()
|
|
143
534
|
}
|
|
144
535
|
}
|
|
145
536
|
|
|
537
|
+
/**
|
|
538
|
+
* PUT レスポンスから「サーバーが実際に記録した更新時刻」を取り出す。
|
|
539
|
+
*
|
|
540
|
+
* 取れなければ null。呼び出し側は null のときメタを書かずに skip する
|
|
541
|
+
* (端末時刻などの推測値を書くと、ずれがメタに固定されて後から追えなくなる)。
|
|
542
|
+
*/
|
|
543
|
+
function putResponseUpdatedAt(data) {
|
|
544
|
+
const v = data?.claude_md_updated_at
|
|
545
|
+
return typeof v === "string" && v ? v : null
|
|
546
|
+
}
|
|
547
|
+
|
|
146
548
|
async function readLocalConfigMd(targetDir, configFilename) {
|
|
147
549
|
try {
|
|
148
550
|
return await fs.readFile(path.join(targetDir, configPaths(configFilename).filename), "utf-8")
|
|
@@ -161,13 +563,38 @@ async function readMeta(targetDir, configFilename) {
|
|
|
161
563
|
return await readJsonOrNull(metaPath)
|
|
162
564
|
}
|
|
163
565
|
|
|
566
|
+
/**
|
|
567
|
+
* 一時ファイルへ書いてから rename する(同一ディレクトリ内なので rename は原子的)。
|
|
568
|
+
*
|
|
569
|
+
* 途中で電源断・プロセス kill が起きても、**半端な内容のファイルが見えない**。
|
|
570
|
+
* meta / base はどちらも「次回 sync の判定材料」なので、壊れた中身が残ると
|
|
571
|
+
* 判定そのものが狂う。
|
|
572
|
+
* ⚠️ 一時ファイル名は `.cockpit-sync/` 直下に作るが、`bakNamePattern` は
|
|
573
|
+
* `makeBakName` の形にしか一致しないため .bak の FIFO 削除には巻き込まれない。
|
|
574
|
+
*/
|
|
575
|
+
async function writeFileAtomic(filePath, content) {
|
|
576
|
+
const tmp = `${filePath}.tmp-${process.pid}-${Math.random().toString(36).slice(2, 8)}`
|
|
577
|
+
try {
|
|
578
|
+
// ⚠️ writeFile と rename を同じ try に入れること。writeFile は「一時ファイルを
|
|
579
|
+
// 作って一部を書いた後」に ENOSPC / quota / I/O エラーで失敗しうるので、
|
|
580
|
+
// cleanup を rename の catch にだけ置くと **一時ファイルが残る**。再試行のたびに
|
|
581
|
+
// 別名で増えるため、ディスク枯渇からの回復をさらに難しくする。
|
|
582
|
+
await fs.writeFile(tmp, content, "utf-8")
|
|
583
|
+
await fs.rename(tmp, filePath)
|
|
584
|
+
} catch (err) {
|
|
585
|
+
// rename 成功後はここへ来ない(=確定済みファイルを cleanup 対象にしない)。
|
|
586
|
+
// cleanup 自体の失敗で元の書込エラーを **上書きしない** よう握り潰す。
|
|
587
|
+
await fs.unlink(tmp).catch(() => {})
|
|
588
|
+
throw err
|
|
589
|
+
}
|
|
590
|
+
}
|
|
591
|
+
|
|
164
592
|
async function writeMeta(targetDir, meta, configFilename) {
|
|
165
593
|
const metaDir = path.join(targetDir, META_DIR)
|
|
166
594
|
await fs.mkdir(metaDir, { recursive: true })
|
|
167
|
-
await
|
|
595
|
+
await writeFileAtomic(
|
|
168
596
|
path.join(metaDir, configPaths(configFilename).metaFile),
|
|
169
597
|
JSON.stringify(meta, null, 2),
|
|
170
|
-
"utf-8",
|
|
171
598
|
)
|
|
172
599
|
}
|
|
173
600
|
|
|
@@ -185,7 +612,88 @@ async function writeBaseBody(targetDir, body, configFilename) {
|
|
|
185
612
|
if (body == null) return
|
|
186
613
|
const metaDir = path.join(targetDir, META_DIR)
|
|
187
614
|
await fs.mkdir(metaDir, { recursive: true })
|
|
188
|
-
await
|
|
615
|
+
await writeFileAtomic(path.join(metaDir, configPaths(configFilename).baseFile), body)
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
/** `.cockpit-sync/<name>.base` を読む。無ければ null。 */
|
|
619
|
+
async function readBaseBody(targetDir, configFilename) {
|
|
620
|
+
try {
|
|
621
|
+
return await fs.readFile(
|
|
622
|
+
path.join(targetDir, META_DIR, configPaths(configFilename).baseFile),
|
|
623
|
+
"utf-8",
|
|
624
|
+
)
|
|
625
|
+
} catch {
|
|
626
|
+
return null
|
|
627
|
+
}
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
/**
|
|
631
|
+
* 同期成功時の状態を **base → meta の順で** 書く(成功経路はすべてこれを通す)。
|
|
632
|
+
*
|
|
633
|
+
* ⚠️ 2 ファイルの書込は原子的にできない。**meta は「判定の commit marker」**なので、
|
|
634
|
+
* meta を先に書くと base の書込が失敗しても「もう同期済み」と見なされ、次回 sync は
|
|
635
|
+
* 通常 noop に入って **base が壊れたまま永久に固定される**(base が誤って
|
|
636
|
+
* ディレクトリになっている / ディスク枯渇 / 個別ファイルの権限などで実際に起きる)。
|
|
637
|
+
*
|
|
638
|
+
* base を先に書けば、失敗しても meta は古いままなので、次回 sync が変化 / 収束を
|
|
639
|
+
* 検出して丸ごとやり直せる。逆順で生まれる「base だけ新しい」中間状態は、
|
|
640
|
+
* 次回 sync の再収束で解消する(同期の判定は meta しか見ないため無害)。
|
|
641
|
+
*
|
|
642
|
+
* meta には `baseHash`(=いま書いた base の hash)を残す。読み手(session-end の
|
|
643
|
+
* 3-way merge / 下の noop 経路)が「disk 上の base が meta の言うとおりの中身か」を
|
|
644
|
+
* 突き合わせられるようにするため。
|
|
645
|
+
*/
|
|
646
|
+
async function writeSyncState(targetDir, { body, meta }, configFilename) {
|
|
647
|
+
await writeBaseBody(targetDir, body, configFilename)
|
|
648
|
+
await writeMeta(
|
|
649
|
+
targetDir,
|
|
650
|
+
body == null ? { ...meta } : { ...meta, baseHash: sha256(body) },
|
|
651
|
+
configFilename,
|
|
652
|
+
)
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
/**
|
|
656
|
+
* meta の `baseHash` と disk 上の base を突き合わせ、食い違っていれば base を書き直す。
|
|
657
|
+
*
|
|
658
|
+
* 直せるのは「いまローカルにある本文が、meta が指す同期済み本文と一致している」ときだけ
|
|
659
|
+
* (= base に入るべき中身が確定しているとき)。それ以外は触らない — 中身が確定しない
|
|
660
|
+
* まま書くと、3-way merge の base に嘘を置くことになる。
|
|
661
|
+
*
|
|
662
|
+
* @returns {Promise<boolean>} 書き直したら true
|
|
663
|
+
*/
|
|
664
|
+
async function repairBaseIfInconsistent({
|
|
665
|
+
targetDir,
|
|
666
|
+
configFilename,
|
|
667
|
+
meta,
|
|
668
|
+
localBody,
|
|
669
|
+
localCurrentHash,
|
|
670
|
+
logger,
|
|
671
|
+
dirName,
|
|
672
|
+
}) {
|
|
673
|
+
// baseHash は writeSyncState が付ける。旧バージョンが書いた meta には無いので、
|
|
674
|
+
// その場合は判定材料が無い=触らない(勝手に書き直さない)。
|
|
675
|
+
if (!meta || typeof meta.baseHash !== "string" || !meta.baseHash) return false
|
|
676
|
+
if (localBody == null || localCurrentHash !== meta.lastSyncedHash) return false
|
|
677
|
+
const base = await readBaseBody(targetDir, configFilename)
|
|
678
|
+
if (base !== null && sha256(base) === meta.baseHash) return false // 整合している
|
|
679
|
+
try {
|
|
680
|
+
await writeSyncState(
|
|
681
|
+
targetDir,
|
|
682
|
+
{ body: localBody, meta: { ...meta, lastSyncedAt: nowIso() } },
|
|
683
|
+
configFilename,
|
|
684
|
+
)
|
|
685
|
+
} catch (err) {
|
|
686
|
+
logger?.warn?.(
|
|
687
|
+
{ dirName, err: err?.message || String(err) },
|
|
688
|
+
"syncClaudeMdWithHub: base repair failed (will retry next sync)",
|
|
689
|
+
)
|
|
690
|
+
return false
|
|
691
|
+
}
|
|
692
|
+
logger?.warn?.(
|
|
693
|
+
{ dirName, base_missing: base === null },
|
|
694
|
+
"syncClaudeMdWithHub: base file was inconsistent with meta; rewrote it",
|
|
695
|
+
)
|
|
696
|
+
return true
|
|
189
697
|
}
|
|
190
698
|
|
|
191
699
|
/**
|
|
@@ -203,9 +711,12 @@ async function backupLocalConfigMd(targetDir, body, kind, configFilename) {
|
|
|
203
711
|
const bakName = makeBakName(prefix)
|
|
204
712
|
await fs.writeFile(path.join(metaDir, bakName), body, "utf-8")
|
|
205
713
|
// 同 prefix の .bak を新しい順に BAK_KEEP 件まで残す。
|
|
714
|
+
// 対象は「hub-agent が makeBakName で作った、この kind の .bak」だけに限定する
|
|
715
|
+
// (conflict 退避や手作業で置かれた異物を巻き添えにしないため。bakNamePattern 参照)。
|
|
716
|
+
const pattern = bakNamePattern(prefix)
|
|
206
717
|
const entries = await fs.readdir(metaDir).catch(() => [])
|
|
207
718
|
const sameKind = entries
|
|
208
|
-
.filter((n) =>
|
|
719
|
+
.filter((n) => pattern.test(n))
|
|
209
720
|
.sort() // 名前にタイムスタンプを含むので辞書順 ≒ 時系列順
|
|
210
721
|
.reverse() // 新しい順
|
|
211
722
|
const obsolete = sameKind.slice(BAK_KEEP)
|
|
@@ -226,6 +737,8 @@ async function backupLocalConfigMd(targetDir, body, kind, configFilename) {
|
|
|
226
737
|
* @param {string} [args.accessToken] 省略時は ~/.claude/.hub_token.json から
|
|
227
738
|
* @param {object} [args.logger]
|
|
228
739
|
* @param {Function} [args.fetchImpl] テスト注入用
|
|
740
|
+
* @param {number} [args.requestTimeoutMs] Hub API 1 往復の期限(既定
|
|
741
|
+
* `HUB_REQUEST_TIMEOUT_MS`)。テスト注入・低速環境の調整用。
|
|
229
742
|
* @returns {Promise<{ action: 'pull'|'push'|'conflict'|'noop'|'skipped', reason?: string }>}
|
|
230
743
|
*/
|
|
231
744
|
export async function syncClaudeMdWithHub({
|
|
@@ -236,6 +749,7 @@ export async function syncClaudeMdWithHub({
|
|
|
236
749
|
logger,
|
|
237
750
|
fetchImpl,
|
|
238
751
|
configFilename = "CLAUDE.md",
|
|
752
|
+
requestTimeoutMs = HUB_REQUEST_TIMEOUT_MS,
|
|
239
753
|
} = {}) {
|
|
240
754
|
if (!targetDir) throw new Error("syncClaudeMdWithHub requires targetDir")
|
|
241
755
|
if (!dirName) throw new Error("syncClaudeMdWithHub requires dirName")
|
|
@@ -255,6 +769,7 @@ export async function syncClaudeMdWithHub({
|
|
|
255
769
|
dirName,
|
|
256
770
|
fetchImpl,
|
|
257
771
|
logger,
|
|
772
|
+
timeoutMs: requestTimeoutMs,
|
|
258
773
|
})
|
|
259
774
|
if (!hub) {
|
|
260
775
|
return { action: "skipped", reason: "hub-not-resolvable" }
|
|
@@ -304,57 +819,140 @@ export async function syncClaudeMdWithHub({
|
|
|
304
819
|
localCurrentHash != null && localCurrentHash !== meta.lastSyncedHash
|
|
305
820
|
}
|
|
306
821
|
|
|
307
|
-
// 同じ内容で書き込まれている(hash
|
|
822
|
+
// 同じ内容で書き込まれている(hash 一致)=内容はすでに収束している。
|
|
823
|
+
//
|
|
824
|
+
// ⚠️ ここで「noop に降格」するだけで meta を放置すると、**内容は同じなのに同期基準
|
|
825
|
+
// だけが古い**状態が固定化する。その後 Hub 側だけを編集すると、meta が古いせいで
|
|
826
|
+
// localChanged も true と判定され、hash 不一致 → conflict → 端末勝者で PUT となり
|
|
827
|
+
// **Hub 側の編集が消える**(実際に踏んだ経路: PUT 成功だが updated_at が取れず
|
|
828
|
+
// meta 未更新 → 次回この分岐 → さらに次で Hub 編集を上書き)。
|
|
829
|
+
// よってこの分岐は「収束を確定させる自己修復」にする。
|
|
308
830
|
if (hubChanged && localChanged && hubHash && hubHash === localCurrentHash) {
|
|
309
|
-
|
|
310
|
-
|
|
831
|
+
// ⚠️ GET の payload を無条件に信用しない。updated_at があり、かつ申告された
|
|
832
|
+
// hash が本文と実際に一致するときだけ同期基準を進める(壊れた payload で
|
|
833
|
+
// 基準を進めると、その後の pull / push の判定が丸ごと狂う)。
|
|
834
|
+
if (hubUpdatedAt && hubHash === sha256(hubBody)) {
|
|
835
|
+
// ⚠️ base → meta の順(writeSyncState)。meta を先に進めると、base の書込が
|
|
836
|
+
// 失敗しても「同期済み」と見なされ、次回 sync が通常 noop に入って
|
|
837
|
+
// base が壊れたまま固定される。
|
|
838
|
+
await writeSyncState(targetDir, {
|
|
839
|
+
body: hubBody,
|
|
840
|
+
meta: {
|
|
841
|
+
lastSyncedHubUpdatedAt: hubUpdatedAt,
|
|
842
|
+
lastSyncedHash: hubHash,
|
|
843
|
+
lastSyncedAt: nowIso(),
|
|
844
|
+
},
|
|
845
|
+
}, configFilename)
|
|
846
|
+
logger?.info?.(
|
|
847
|
+
{ dirName },
|
|
848
|
+
"syncClaudeMdWithHub: converged (meta/base repaired to hub state)",
|
|
849
|
+
)
|
|
850
|
+
return { action: "noop", reason: "converged" }
|
|
851
|
+
}
|
|
852
|
+
logger?.warn?.(
|
|
853
|
+
{ dirName, has_updated_at: !!hubUpdatedAt },
|
|
854
|
+
"syncClaudeMdWithHub: converged but hub payload is inconsistent; meta not advanced",
|
|
855
|
+
)
|
|
856
|
+
return { action: "noop", reason: "converged-untrusted-payload" }
|
|
311
857
|
}
|
|
312
858
|
|
|
313
859
|
// 4) アクション決定
|
|
314
860
|
if (!hubChanged && !localChanged) {
|
|
315
|
-
|
|
861
|
+
// 変化は無いが、**base が meta の言うとおりの中身か**だけ確かめる。
|
|
862
|
+
// base の書込だけが失敗した中間状態(base → meta の順で書くので meta が古く
|
|
863
|
+
// 残る)は次回の変化検出で解消するが、同期後に base が壊された / 消された
|
|
864
|
+
// ケースはここでしか気づけない。3-way merge が壊れた base を使うのを防ぐ。
|
|
865
|
+
const repaired = await repairBaseIfInconsistent({
|
|
866
|
+
targetDir,
|
|
867
|
+
configFilename,
|
|
868
|
+
meta,
|
|
869
|
+
localBody,
|
|
870
|
+
localCurrentHash,
|
|
871
|
+
logger,
|
|
872
|
+
dirName,
|
|
873
|
+
})
|
|
874
|
+
return repaired ? { action: "noop", reason: "base-repaired" } : { action: "noop" }
|
|
316
875
|
}
|
|
317
876
|
|
|
318
877
|
if (hubChanged && !localChanged) {
|
|
319
878
|
// pull: ローカルを .bak 退避 → Hub の本文で上書き
|
|
320
879
|
await backupLocalConfigMd(targetDir, localBody, "normal", configFilename)
|
|
321
880
|
await writeLocalConfigMd(targetDir, hubBody, configFilename)
|
|
322
|
-
await writeMeta(targetDir, {
|
|
323
|
-
lastSyncedHubUpdatedAt: hubUpdatedAt,
|
|
324
|
-
lastSyncedHash: hubHash || sha256(hubBody),
|
|
325
|
-
lastSyncedAt: nowIso(),
|
|
326
|
-
}, configFilename)
|
|
327
881
|
// 3-way merge 用 base: pull 直後はローカル == Hub なので Hub 本文を base に。
|
|
328
|
-
|
|
882
|
+
// base → meta の順で書く(writeSyncState の JSDoc 参照)。
|
|
883
|
+
await writeSyncState(targetDir, {
|
|
884
|
+
body: hubBody,
|
|
885
|
+
meta: {
|
|
886
|
+
lastSyncedHubUpdatedAt: hubUpdatedAt,
|
|
887
|
+
lastSyncedHash: hubHash || sha256(hubBody),
|
|
888
|
+
lastSyncedAt: nowIso(),
|
|
889
|
+
},
|
|
890
|
+
}, configFilename)
|
|
329
891
|
logger?.info?.({ dirName }, "syncClaudeMdWithHub: pull")
|
|
330
892
|
return { action: "pull" }
|
|
331
893
|
}
|
|
332
894
|
|
|
333
895
|
if (!hubChanged && localChanged) {
|
|
334
896
|
// push: Hub に PUT。成功時にメタ更新。
|
|
335
|
-
const
|
|
897
|
+
const put = await putHubClaudeMd({
|
|
336
898
|
hubUrl: url,
|
|
337
899
|
accessToken: token,
|
|
338
900
|
dirName,
|
|
339
901
|
body: localBody || "",
|
|
902
|
+
// 楽観ロック: この sync が GET で読んだ時点の hash を渡す。読んだ後に Hub が
|
|
903
|
+
// 動いていれば backend が 409 を返すので、盲目的な上書きにならない。
|
|
904
|
+
expectedHash: hubHash || undefined,
|
|
340
905
|
fetchImpl,
|
|
341
906
|
logger,
|
|
907
|
+
timeoutMs: requestTimeoutMs,
|
|
342
908
|
})
|
|
343
|
-
if (!ok) {
|
|
909
|
+
if (!put.ok) {
|
|
910
|
+
if (put.status === 409) {
|
|
911
|
+
// 409 = 読んだ後に Hub 側が動いた。通信失敗ではないので理由を分ける。
|
|
912
|
+
// メタは更新せず、次回 sync で hubChanged 込みで判定し直す。
|
|
913
|
+
logger?.warn?.(
|
|
914
|
+
{ dirName },
|
|
915
|
+
"syncClaudeMdWithHub: push rejected by optimistic lock (hub moved); retry next sync",
|
|
916
|
+
)
|
|
917
|
+
return { action: "skipped", reason: "hub-put-stale" }
|
|
918
|
+
}
|
|
919
|
+
if (put.error?.error === "config_md_kind_mismatch") {
|
|
920
|
+
// CLI 種別の取り違え(AGENTS.md の内容を CLAUDE.md 正本へ送った等)。
|
|
921
|
+
// 同じ本文を再送しても永久に通らないので、リトライ前提の通信失敗と混ぜない。
|
|
922
|
+
logger?.warn?.(
|
|
923
|
+
{ dirName, ...put.error },
|
|
924
|
+
"syncClaudeMdWithHub: push rejected (config md kind mismatch); resending the same body will keep failing",
|
|
925
|
+
)
|
|
926
|
+
return { action: "skipped", reason: "hub-put-kind-mismatch" }
|
|
927
|
+
}
|
|
344
928
|
return { action: "skipped", reason: "hub-put-failed" }
|
|
345
929
|
}
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
930
|
+
// 次回 sync の hubChanged 判定は lastSyncedHubUpdatedAt との文字列比較なので、
|
|
931
|
+
// 端末の時刻ではなく **サーバーが返した実際の値** を入れないと必ず不一致になる
|
|
932
|
+
// (Django はマイクロ秒 6 桁 / nowIso() はミリ秒 3 桁)。
|
|
933
|
+
const putUpdatedAt = putResponseUpdatedAt(put.data)
|
|
934
|
+
if (!putUpdatedAt) {
|
|
935
|
+
// 推測値を書かない。書かなければ次回 sync でやり直せる。
|
|
936
|
+
logger?.warn?.(
|
|
937
|
+
{ dirName },
|
|
938
|
+
"syncClaudeMdWithHub: PUT succeeded but response had no claude_md_updated_at; meta not updated",
|
|
939
|
+
)
|
|
940
|
+
return { action: "skipped", reason: "hub-put-missing-updated-at" }
|
|
941
|
+
}
|
|
355
942
|
// 3-way merge 用 base: push 直後は Hub の正本がローカルと一致するので、
|
|
356
|
-
// 次回の base は今 push した本文 (= localBody)。
|
|
357
|
-
await
|
|
943
|
+
// 次回の base は今 push した本文 (= localBody)。base → meta の順で書く。
|
|
944
|
+
await writeSyncState(targetDir, {
|
|
945
|
+
body: localBody || "",
|
|
946
|
+
meta: {
|
|
947
|
+
// lastSyncedHubUpdatedAt は「Hub 側の版を指す識別子」で、次回 sync の
|
|
948
|
+
// hubChanged 判定に使う。必ずサーバー値を入れること。
|
|
949
|
+
lastSyncedHubUpdatedAt: putUpdatedAt,
|
|
950
|
+
lastSyncedHash: localCurrentHash,
|
|
951
|
+
// lastSyncedAt は「この端末がいつ同期したか」の記録で、判定には使わない。
|
|
952
|
+
// 用途が違うので putUpdatedAt と混同しないこと。
|
|
953
|
+
lastSyncedAt: nowIso(),
|
|
954
|
+
},
|
|
955
|
+
}, configFilename)
|
|
358
956
|
logger?.info?.({ dirName }, "syncClaudeMdWithHub: push")
|
|
359
957
|
return { action: "push" }
|
|
360
958
|
}
|
|
@@ -362,25 +960,56 @@ export async function syncClaudeMdWithHub({
|
|
|
362
960
|
// hubChanged && localChanged → 衝突。端末側を勝者にして Hub に PUT。
|
|
363
961
|
// Hub の取り込み内容は .bak.conflict に退避(後で人手マージできるよう)。
|
|
364
962
|
await backupLocalConfigMd(targetDir, hubBody, "conflict", configFilename)
|
|
365
|
-
const
|
|
963
|
+
const put = await putHubClaudeMd({
|
|
366
964
|
hubUrl: url,
|
|
367
965
|
accessToken: token,
|
|
368
966
|
dirName,
|
|
369
967
|
body: localBody || "",
|
|
968
|
+
// push 経路と同じく、GET 時点の hash で楽観ロックを効かせる。
|
|
969
|
+
expectedHash: hubHash || undefined,
|
|
370
970
|
fetchImpl,
|
|
371
971
|
logger,
|
|
972
|
+
timeoutMs: requestTimeoutMs,
|
|
372
973
|
})
|
|
373
|
-
if (!ok) {
|
|
974
|
+
if (!put.ok) {
|
|
374
975
|
// PUT 失敗時はメタを更新しない(次回再試行)。ローカルは無傷。
|
|
976
|
+
if (put.status === 409) {
|
|
977
|
+
logger?.warn?.(
|
|
978
|
+
{ dirName },
|
|
979
|
+
"syncClaudeMdWithHub: conflict push rejected by optimistic lock (hub moved); retry next sync",
|
|
980
|
+
)
|
|
981
|
+
return { action: "skipped", reason: "hub-put-stale-in-conflict" }
|
|
982
|
+
}
|
|
983
|
+
if (put.error?.error === "config_md_kind_mismatch") {
|
|
984
|
+
// push 経路と同じ理由(再送しても通らない拒否)。通信失敗と区別する。
|
|
985
|
+
logger?.warn?.(
|
|
986
|
+
{ dirName, ...put.error },
|
|
987
|
+
"syncClaudeMdWithHub: conflict push rejected (config md kind mismatch); resending the same body will keep failing",
|
|
988
|
+
)
|
|
989
|
+
return { action: "skipped", reason: "hub-put-kind-mismatch-in-conflict" }
|
|
990
|
+
}
|
|
375
991
|
return { action: "skipped", reason: "hub-put-failed-in-conflict" }
|
|
376
992
|
}
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
993
|
+
const putUpdatedAt = putResponseUpdatedAt(put.data)
|
|
994
|
+
if (!putUpdatedAt) {
|
|
995
|
+
logger?.warn?.(
|
|
996
|
+
{ dirName },
|
|
997
|
+
"syncClaudeMdWithHub: conflict PUT succeeded but response had no claude_md_updated_at; meta not updated",
|
|
998
|
+
)
|
|
999
|
+
return { action: "skipped", reason: "hub-put-missing-updated-at-in-conflict" }
|
|
1000
|
+
}
|
|
382
1001
|
// 3-way merge 用 base: conflict push 後の Hub 正本は端末側本文 (= localBody)。
|
|
383
|
-
|
|
1002
|
+
// base → meta の順で書く(writeSyncState の JSDoc 参照)。
|
|
1003
|
+
await writeSyncState(targetDir, {
|
|
1004
|
+
body: localBody || "",
|
|
1005
|
+
meta: {
|
|
1006
|
+
// push 経路と同じ理由でサーバー値を使う(端末時刻を入れると次回必ず hubChanged)。
|
|
1007
|
+
lastSyncedHubUpdatedAt: putUpdatedAt,
|
|
1008
|
+
lastSyncedHash: localCurrentHash,
|
|
1009
|
+
// lastSyncedAt は端末側の同期時刻。判定には使わない。
|
|
1010
|
+
lastSyncedAt: nowIso(),
|
|
1011
|
+
},
|
|
1012
|
+
}, configFilename)
|
|
384
1013
|
logger?.warn?.(
|
|
385
1014
|
{ dirName },
|
|
386
1015
|
"syncClaudeMdWithHub: conflict (terminal wins); hub body saved to .bak.conflict",
|
|
@@ -391,7 +1020,19 @@ export async function syncClaudeMdWithHub({
|
|
|
391
1020
|
// メタ操作・ハッシュ計算は ensureClaudeMd 側 (Hub pull 初回配信) でも使うため
|
|
392
1021
|
// named export する。同じファイルパス・同じスキーマで管理することで、初回配信と
|
|
393
1022
|
// 以降の sync で食い違いを起こさない。
|
|
394
|
-
|
|
1023
|
+
// ⚠️ 新規の呼び出し元は `writeMeta` / `writeBaseBody` を個別に呼ばず
|
|
1024
|
+
// **`writeSyncState` を使うこと**(base → meta の順序と baseHash の付与を一箇所に
|
|
1025
|
+
// 集約するため。順序を誤ると base 書込失敗時に永久に再修復されない)。
|
|
1026
|
+
export {
|
|
1027
|
+
sha256,
|
|
1028
|
+
readMeta,
|
|
1029
|
+
writeMeta,
|
|
1030
|
+
writeBaseBody,
|
|
1031
|
+
writeSyncState,
|
|
1032
|
+
readBaseBody,
|
|
1033
|
+
nowIso,
|
|
1034
|
+
configPaths,
|
|
1035
|
+
}
|
|
395
1036
|
|
|
396
1037
|
// 内部テスト用エクスポート
|
|
397
1038
|
export const _internals = {
|
|
@@ -399,7 +1040,13 @@ export const _internals = {
|
|
|
399
1040
|
readMeta,
|
|
400
1041
|
writeMeta,
|
|
401
1042
|
writeBaseBody,
|
|
1043
|
+
writeSyncState,
|
|
1044
|
+
readBaseBody,
|
|
402
1045
|
backupLocalConfigMd,
|
|
403
1046
|
BAK_KEEP,
|
|
404
1047
|
configPaths,
|
|
1048
|
+
// 「生成する名前」と「FIFO 対象を選ぶ正規表現」が食い違うと保持ロジックが静かに
|
|
1049
|
+
// 壊れるため、両者を突き合わせるテストから参照できるように公開する。
|
|
1050
|
+
makeBakName,
|
|
1051
|
+
bakNamePattern,
|
|
405
1052
|
}
|