genetics-mcp 2.18.2 → 2.19.0
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/bin/aarch64-apple-darwin/gcell +0 -0
- package/bin/aarch64-unknown-linux-musl/gcell +0 -0
- package/bin/manifest.json +4 -4
- package/bin/x86_64-apple-darwin/gcell +0 -0
- package/bin/x86_64-unknown-linux-musl/gcell +0 -0
- package/genetics.mjs +2390 -262
- package/package.json +3 -2
- package/skills/gazette-report/scripts/package-lock.json +24 -0
- package/skills/genetics-cell-id/SKILL.md +5 -4
- package/skills/genetics-define/SKILL.md +223 -0
- package/skills/genetics-define/references/contract-map.js +134 -0
- package/skills/genetics-define/references/fresh-reader-test.js +128 -0
- package/skills/genetics-report/SKILL.md +6 -4
- package/skills/genetics-report/assets/diagram.mjs +208 -0
- package/skills/genetics-report/assets/example//354/230/210/354/213/234-/353/263/264/352/263/240/354/204/234.html +2258 -0
- package/skills/genetics-report/references/diagrams.md +105 -0
- package/skills/genetics-report/references/history.md +2 -0
- package/skills/genetics-report/references/lim-portal-format.md +110 -4
- package/skills/genetics-report/references/process.md +2 -2
- package/skills/genetics-sync/SKILL.md +7 -4
package/genetics.mjs
CHANGED
|
@@ -150,12 +150,293 @@ function credRead() {
|
|
|
150
150
|
} catch { return null; }
|
|
151
151
|
}
|
|
152
152
|
|
|
153
|
+
/* ── 잠금과 원자 쓰기 (DESIGN-genetics-ssot.md §5.2) ──────────────────────
|
|
154
|
+
*
|
|
155
|
+
* 이 기계에는 MCP 창이 수십 개 돈다. 읽기→병합→**절단 쓰기**를 잠금 없이 하면 두 창이 같은
|
|
156
|
+
* 순간에 쓸 때 한쪽 행이 사라지고, 쓰는 도중에 읽은 창은 반쪽 JSON 을 본다. 그래서 자격·색인·
|
|
157
|
+
* resolve 캐시는 전부 이 한 길로만 쓴다:
|
|
158
|
+
* 잠금(`<파일>.lock`, O_EXCL) → 잠금 안에서 다시 읽기 → 병합 → 값이 같으면 안 쓴다
|
|
159
|
+
* → `<파일>.tmp-<pid>-<rand>` 에 쓰고 fsync → rename.
|
|
160
|
+
* 못 읽는 파일은 지우지 않고 `<파일>.corrupt-<ISO>` 로 옮긴 뒤 {} 에서 시작한다.
|
|
161
|
+
*/
|
|
162
|
+
const LOCK_STALE_MS = 30_000;
|
|
163
|
+
const LOCK_WAIT_MS = 5;
|
|
164
|
+
const LOCK_TRIES = 400; // 5ms × 400 = 2초. 못 잡으면 이번 쓰기를 포기한다
|
|
165
|
+
const lockSleepCell = new Int32Array(new SharedArrayBuffer(4));
|
|
166
|
+
|
|
167
|
+
function isoStamp() { return new Date().toISOString().replace(/[:.]/g, '-'); }
|
|
168
|
+
|
|
169
|
+
/** 잠금 파일이 죽은 자의 것인가 — 같은 호스트의 죽은 pid 이거나 30초 넘게 묵었다. 그때 그 내용을, 아니면 null. */
|
|
170
|
+
function lockStaleText(lock) {
|
|
171
|
+
let st, text;
|
|
172
|
+
try { st = fs.statSync(lock); text = fs.readFileSync(lock, 'utf8'); } catch { return null; }
|
|
173
|
+
if (Date.now() - st.mtimeMs > LOCK_STALE_MS) return text;
|
|
174
|
+
const [pid, host] = text.split('\n');
|
|
175
|
+
if (host === os.hostname() && /^[1-9][0-9]*$/.test(pid || '')) {
|
|
176
|
+
try { process.kill(Number(pid), 0); } catch (e) { if (e?.code === 'ESRCH') return text; }
|
|
177
|
+
}
|
|
178
|
+
return null;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* 묵은 잠금을 **원자적으로** 걷는다(호환 검토 L12).
|
|
183
|
+
*
|
|
184
|
+
* 전에는 「읽고 → 견주고 → unlink」였다. 그 사이에 다른 창이 잠금을 걷고 새로 잡으면 우리가 **그 새 잠금을**
|
|
185
|
+
* 지워, 둘이 동시에 잠금을 쥔다. rename 은 원자적이라 **한 쪽만 성공한다** — 옮긴 뒤 내용이 그 묵은 것이
|
|
186
|
+
* 맞을 때만 버리고, 아니면(그 사이 새로 잡혔다) 제자리로 돌려놓는다.
|
|
187
|
+
*/
|
|
188
|
+
function lockBreakStale(lock, stale) {
|
|
189
|
+
const aside = `${lock}.stale-${process.pid}-${crypto.randomBytes(4).toString('hex')}`;
|
|
190
|
+
try { fs.renameSync(lock, aside); } catch { return; } // 남이 먼저 걷었다 — 다시 잡아 본다
|
|
191
|
+
let text = null;
|
|
192
|
+
try { text = fs.readFileSync(aside, 'utf8'); } catch { /* 못 읽으면 버린다 */ }
|
|
193
|
+
if (text !== null && text !== stale) {
|
|
194
|
+
try { fs.renameSync(aside, lock); return; } catch { /* 자리에 못 돌려놓으면 아래에서 버린다 */ }
|
|
195
|
+
}
|
|
196
|
+
try { fs.unlinkSync(aside); } catch { /* 이미 없다 */ }
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* 이 파일 자리의 남은 조각을 한 번 걷는다 — SIGKILL 로 죽은 창이 남긴 `.tmp-*`·`.stale-*`(L12).
|
|
201
|
+
* 잠금 안에서, 이 프로세스에서 파일마다 한 번만. 묵은 것(30초)만 본다 — 지금 쓰는 중인 남의 tmp 를 안 건드린다.
|
|
202
|
+
*/
|
|
203
|
+
const sweptDirs = new Set();
|
|
204
|
+
function sweepLeftovers(file) {
|
|
205
|
+
if (sweptDirs.has(file)) return;
|
|
206
|
+
sweptDirs.add(file);
|
|
207
|
+
const base = path.basename(file);
|
|
208
|
+
const dir = path.dirname(file);
|
|
209
|
+
const now = Date.now();
|
|
210
|
+
try {
|
|
211
|
+
for (const n of fs.readdirSync(dir)) {
|
|
212
|
+
if (!n.startsWith(`${base}.tmp-`) && !n.startsWith(`${base}.lock.stale-`)) continue;
|
|
213
|
+
const f = path.join(dir, n);
|
|
214
|
+
try { if (now - fs.statSync(f).mtimeMs > LOCK_STALE_MS) fs.rmSync(f, { force: true }); } catch { /* 남이 지웠다 */ }
|
|
215
|
+
}
|
|
216
|
+
} catch { /* 자리를 못 읽으면 그냥 넘어간다 */ }
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* @param {number} [deadline] epoch ms — 이 시각을 넘기면 더 안 기다린다(0 이면 상한 없음, 호환 N9).
|
|
221
|
+
* 기다림은 `Atomics.wait` 이라 **이 고리가 곧 MCP 의 stdio 고리다** — 한 번의 쓰기가 몇 초씩 잡으면 심박도 도구 응답도 멎는다.
|
|
222
|
+
*/
|
|
223
|
+
function fileLock(file, deadline = 0) {
|
|
224
|
+
const lock = `${file}.lock`;
|
|
225
|
+
const token = `${process.pid}\n${os.hostname()}\n${Date.now()}`;
|
|
226
|
+
let broke = false;
|
|
227
|
+
// deadline 0 = 첫 시도(jsonFileUpdate) — §5.2 대로 **상한 없이** 기다린다(묵은 잠금은 30초에 걷힌다). 2초(LOCK_TRIES)
|
|
228
|
+
// 에서 포기하면 그 쓰기는 `nolock` 으로 되풀이 없이 사라진다 — 창 열둘이 한 파일을 두드리는 관문에서 599/600(2026-09-16 실측).
|
|
229
|
+
// 되풀이 시도(deadline > 0)만 예산 안에서 자른다.
|
|
230
|
+
for (let i = 0; deadline || i < LOCK_TRIES; i++) {
|
|
231
|
+
try {
|
|
232
|
+
const fd = fs.openSync(lock, 'wx', 0o600);
|
|
233
|
+
try { fs.writeSync(fd, token); } finally { fs.closeSync(fd); }
|
|
234
|
+
return { lock, token };
|
|
235
|
+
} catch (e) {
|
|
236
|
+
if (e?.code !== 'EEXIST') return null;
|
|
237
|
+
}
|
|
238
|
+
if (!broke) {
|
|
239
|
+
const stale = lockStaleText(lock);
|
|
240
|
+
if (stale !== null) {
|
|
241
|
+
broke = true; // 한 번만 걷고 다시 잡는다
|
|
242
|
+
lockBreakStale(lock, stale);
|
|
243
|
+
continue;
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
if (deadline && Date.now() + LOCK_WAIT_MS > deadline) return null;
|
|
247
|
+
Atomics.wait(lockSleepCell, 0, 0, LOCK_WAIT_MS);
|
|
248
|
+
}
|
|
249
|
+
return null;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/** 내 토큰일 때만 걷는다 — 남이 새로 잡은 잠금을 지우지 않는다. */
|
|
253
|
+
function fileUnlock(h) {
|
|
254
|
+
if (!h) return;
|
|
255
|
+
try { if (fs.readFileSync(h.lock, 'utf8') === h.token) fs.unlinkSync(h.lock); } catch { /* 이미 없다 */ }
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
function writeAtomic(file, text, mode) {
|
|
259
|
+
const tmp = `${file}.tmp-${process.pid}-${crypto.randomBytes(4).toString('hex')}`;
|
|
260
|
+
let fd = null;
|
|
261
|
+
try {
|
|
262
|
+
fd = fs.openSync(tmp, 'wx', mode);
|
|
263
|
+
fs.fchmodSync(fd, mode); // umask 와 무관하게 먼저 좁힌다
|
|
264
|
+
fs.writeSync(fd, text);
|
|
265
|
+
fs.fsyncSync(fd);
|
|
266
|
+
fs.closeSync(fd);
|
|
267
|
+
fd = null;
|
|
268
|
+
fs.renameSync(tmp, file);
|
|
269
|
+
} catch (e) {
|
|
270
|
+
if (fd !== null) { try { fs.closeSync(fd); } catch { /* */ } }
|
|
271
|
+
try { fs.rmSync(tmp, { force: true }); } catch { /* */ }
|
|
272
|
+
throw e;
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
/**
|
|
277
|
+
* 잠금 안에서 읽고 고쳐 쓴다.
|
|
278
|
+
*
|
|
279
|
+
* @param {string} file
|
|
280
|
+
* @param {(cur: object) => (object|null)} mutate 방금 읽은 값(못 읽었으면 {})을 받아 쓸 값을 준다. null 이면 안 쓴다
|
|
281
|
+
* @param {object} [o]
|
|
282
|
+
* @param {(v: object) => string} [o.serialize]
|
|
283
|
+
* @param {(raw: string|null, cur: object) => void} [o.before] rename 직전(잠금 안)에 한 번 — 사본 뜨기 자리
|
|
284
|
+
* @return {'written'|'same'|'nolock'|'busy'|'failed'} `busy` = 남이 쓰는 중이라 못 읽었다(옮기지 않았다, §5.2 H2)
|
|
285
|
+
*/
|
|
286
|
+
/**
|
|
287
|
+
* 한 번 읽어 판다 — 온전한 객체면 그것을, 아니면 null. **빈 파일도 null** 이다(절단 창).
|
|
288
|
+
* @return {{raw: string|null, obj: object|null}} raw 가 null 이면 파일이 없다
|
|
289
|
+
*/
|
|
290
|
+
function jsonReadOnce(file) {
|
|
291
|
+
let raw = null;
|
|
292
|
+
try { raw = fs.readFileSync(file, 'utf8'); } catch (e) { if (e?.code !== 'ENOENT') return { raw: null, obj: null, err: true }; }
|
|
293
|
+
if (raw === null) return { raw: null, obj: null };
|
|
294
|
+
if (raw.trim() === '') return { raw, obj: null };
|
|
295
|
+
try {
|
|
296
|
+
const j = JSON.parse(raw);
|
|
297
|
+
return { raw, obj: (j && typeof j === 'object' && !Array.isArray(j)) ? j : null };
|
|
298
|
+
} catch { return { raw, obj: null }; }
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
/*
|
|
302
|
+
* ⚠ **절단은 고장이 아니라 창(窓)이다**(호환 검토 H2, 실측). 잠금에 참여하지 않는 옛 판(≤2.18)의 `indexNote` 는
|
|
303
|
+
* `'w'` 로 열어 **비운 뒤** 채운다. 그 몇 마이크로초 안에 우리가 읽으면 파일이 **비어 있거나 반쪽**이다.
|
|
304
|
+
* 전에는 그것을 「못 읽는 파일」로 보고 `.corrupt-<ISO>` 로 **옮겨** 버렸다 — 옛 판이 쓰던 남의 행 전부와
|
|
305
|
+
* 그 안의 `auto.hold`(「이 대화는 올리지 마라」)가 그 한 번에 사라졌다.
|
|
306
|
+
* 그래서 **다시 읽는다**: 비었거나 반쪽이면 20ms 씩 세 번 더 본다. 그 사이 옛 판은 쓰기를 끝낸다.
|
|
307
|
+
* 그래도 안 읽히면 **파일의 나이를 본다** — 방금(2초 안) 손댄 파일이면 그것은 고장이 아니라 아직 안 끝난
|
|
308
|
+
* 남의 쓰기다. 옮기지 않고 **이번 쓰기를 포기**한다(`busy`). 다음 바퀴가 다시 본다.
|
|
309
|
+
* 묵은 파일(2초 넘게 그 꼴로 있는 것)일 때만 옮기고, 옮긴 사본은 **다섯 벌까지만** 남긴다(무한히 쌓이던 자리).
|
|
310
|
+
*/
|
|
311
|
+
const JSON_RETRY = 3;
|
|
312
|
+
const JSON_RETRY_MS = 20;
|
|
313
|
+
const CORRUPT_KEEP = 5;
|
|
314
|
+
const TRUNCATE_WINDOW_MS = 2000; // 이 안에 손댄 못 읽는 파일은 **절단 창**으로 본다 — 안 옮긴다
|
|
315
|
+
|
|
316
|
+
function jsonReadRetry(file) {
|
|
317
|
+
let r = jsonReadOnce(file);
|
|
318
|
+
for (let i = 0; i < JSON_RETRY && r.raw !== null && r.obj === null; i++) {
|
|
319
|
+
Atomics.wait(lockSleepCell, 0, 0, JSON_RETRY_MS);
|
|
320
|
+
r = jsonReadOnce(file);
|
|
321
|
+
}
|
|
322
|
+
return r;
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
/** 옮겨 둔 사본을 다섯 벌로 줄인다 — 이름이 ISO 라 사전순이 곧 시간순이다. */
|
|
326
|
+
function corruptTrim(file) {
|
|
327
|
+
const stem = `${path.basename(file)}.corrupt-`;
|
|
328
|
+
try {
|
|
329
|
+
const olds = fs.readdirSync(path.dirname(file)).filter((n) => n.startsWith(stem)).sort();
|
|
330
|
+
for (const n of olds.slice(0, Math.max(0, olds.length - CORRUPT_KEEP))) {
|
|
331
|
+
try { fs.rmSync(path.join(path.dirname(file), n), { force: true }); } catch { /* 남이 지웠다 */ }
|
|
332
|
+
}
|
|
333
|
+
} catch { /* 자리를 못 읽으면 그냥 넘어간다 */ }
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
/**
|
|
337
|
+
* 못 읽는 파일 하나를 어떻게 할 것인가(잠금 안에서만 부른다).
|
|
338
|
+
*
|
|
339
|
+
* @return {'busy'|'gone'|'moved'} `busy`=아직 남이 쓰는 중이다(안 옮긴다, 이번 쓰기를 다시 잡는다) ·
|
|
340
|
+
* `gone`=그 사이 사라졌다 · `moved`=묵어서 `.corrupt-<ISO>` 로 옮겼다. `gone`·`moved` 는 `{}` 에서 시작한다.
|
|
341
|
+
*
|
|
342
|
+
* ⚠ **미래의 mtime 도 절단 창이다**(호환 검토 N9). 시계가 어긋난 기계·NFS 에서는 나이가 **음수**로 나오는데,
|
|
343
|
+
* 전에는 `age >= 0` 을 함께 물어 그런 파일을 「묵었다」로 보고 **지금 쓰는 중인 남의 파일을 옮겼다** —
|
|
344
|
+
* H2 가 막으려던 바로 그 일이다. 나이를 모르겠으면 안 옮기는 쪽이 맞다.
|
|
345
|
+
* ⚠ 그 사이 파일이 **사라졌으면**(남이 지웠다·옮겼다) 옮길 것이 없다. 전에는 `renameSync` 의 ENOENT 가 바깥
|
|
346
|
+
* catch 로 떨어져 이번 쓰기가 `failed` 로 **조용히 버려졌다**. 없는 파일은 고장이 아니라 빈 자리다.
|
|
347
|
+
*/
|
|
348
|
+
function jsonCorruptAside(file) {
|
|
349
|
+
let age = Infinity;
|
|
350
|
+
try { age = Date.now() - fs.statSync(file).mtimeMs; } catch (e) { if (e?.code === 'ENOENT') return 'gone'; }
|
|
351
|
+
if (age < TRUNCATE_WINDOW_MS) return 'busy';
|
|
352
|
+
const to = `${file}.corrupt-${isoStamp()}`;
|
|
353
|
+
try {
|
|
354
|
+
fs.renameSync(file, to);
|
|
355
|
+
} catch (e) {
|
|
356
|
+
if (e?.code === 'ENOENT') return 'gone';
|
|
357
|
+
return 'busy'; // 못 옮겼다 — 쓰기를 버리지 말고 다시 잡는다(`failed` 는 이 쓰기를 통째로 잃는다)
|
|
358
|
+
}
|
|
359
|
+
corruptTrim(file);
|
|
360
|
+
try {
|
|
361
|
+
process.stderr.write(`genetics: ${path.basename(file)} 를 못 읽어 ${path.basename(to)} 로 옮겨 두고 새로 시작한다(내 행은 다시 세운다).\n`);
|
|
362
|
+
} catch { /* */ }
|
|
363
|
+
return 'moved';
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
/**
|
|
367
|
+
* 절단 창에 걸리면(`busy`) **잠깐 기다렸다 다시 잡는다.**
|
|
368
|
+
*
|
|
369
|
+
* ⚠ 이 되풀이가 없으면 H2 의 고침이 새 구멍을 낸다(V1 이 599/600 으로 잡았다): 옛 판이 비운 그 순간에 걸린
|
|
370
|
+
* 쓰기는 `mutate` 가 아예 안 돌아 **메모리 사본(`indexMine`)에도 안 남고**, 그래서 그 뒤의 자가 복구가
|
|
371
|
+
* 되세울 행조차 없다. 「옮기지 않는다」와 「내 행은 반드시 되세운다」를 함께 지키려면 여기서 다시 잡아야 한다.
|
|
372
|
+
* 그래도 안 되면 그때는 포기한다 — 색인은 있으면 좋은 것이지, 남의 행을 부수면서까지 지킬 것이 아니다.
|
|
373
|
+
*/
|
|
374
|
+
const BUSY_RETRY = 4;
|
|
375
|
+
const BUSY_RETRY_MS = 25;
|
|
376
|
+
/*
|
|
377
|
+
* ⚠ **한 번의 쓰기가 stdio 고리를 이만큼 넘게 막지 않는다**(호환 검토 N9). 기다림은 전부 `Atomics.wait` 이라
|
|
378
|
+
* **동기**다 — 그동안 심박도, 도구 응답도, 프로토콜 읽기도 멎는다. 되풀이를 넣으면서 최악이 곱해졌다:
|
|
379
|
+
* 한 번 잡는 데 2초(LOCK_TRIES × LOCK_WAIT_MS) × 다섯 번 = 10.4초. 그래서 **되풀이에 상한 하나**를 둔다 —
|
|
380
|
+
* 첫 시도는 §5.2 의 기다림을 그대로 쓰고(자르면 그 쓰기를 잃는다), 두 번째부터의 시도만 이 상한 안에서 한다.
|
|
381
|
+
* 달라지는 것은 **되풀이가 그 값을 곱하지 못한다**는 것뿐이다.
|
|
382
|
+
*/
|
|
383
|
+
const UPDATE_BUDGET_MS = 2500;
|
|
384
|
+
|
|
385
|
+
function jsonFileUpdate(file, mutate, o = {}) {
|
|
386
|
+
const deadline = Date.now() + UPDATE_BUDGET_MS;
|
|
387
|
+
for (let i = 0; ; i++) {
|
|
388
|
+
/*
|
|
389
|
+
* ⚠ **첫 시도는 §5.2 의 기다림을 그대로 쓴다**(상한 없음). 여기에 상한을 걸었더니 V1 이 바로 빨개졌다
|
|
390
|
+
* (599/600) — 창 열둘이 한 파일을 두드리면 한 번 잡는 데 2초를 넘게 쓰는 시도가 나오고, 그것을 잘라
|
|
391
|
+
* `nolock` 으로 돌리면 그 행은 **되풀이도 없이** 사라진다(`nolock` 은 다시 안 잡는다).
|
|
392
|
+
* 상한이 막는 것은 그 기다림이 아니라 **되풀이가 그 값을 곱하는 것**이다.
|
|
393
|
+
*/
|
|
394
|
+
const r = jsonFileUpdateOnce(file, mutate, o, i === 0 ? 0 : deadline);
|
|
395
|
+
if (r !== 'busy' || i >= BUSY_RETRY || Date.now() + BUSY_RETRY_MS >= deadline) return r;
|
|
396
|
+
Atomics.wait(lockSleepCell, 0, 0, BUSY_RETRY_MS);
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
function jsonFileUpdateOnce(file, mutate, o = {}, deadline = 0) {
|
|
401
|
+
try { fs.mkdirSync(path.dirname(file), { recursive: true, mode: 0o700 }); } catch { return 'failed'; }
|
|
402
|
+
const h = fileLock(file, deadline);
|
|
403
|
+
if (!h) return 'nolock';
|
|
404
|
+
try {
|
|
405
|
+
sweepLeftovers(file);
|
|
406
|
+
const got = jsonReadRetry(file);
|
|
407
|
+
if (got.err) return 'failed'; // 있는데 못 읽는다(권한 등) — 옮기지 않는다
|
|
408
|
+
let raw = got.raw;
|
|
409
|
+
let cur = {};
|
|
410
|
+
if (raw !== null) {
|
|
411
|
+
if (got.obj) {
|
|
412
|
+
cur = got.obj;
|
|
413
|
+
} else {
|
|
414
|
+
if (jsonCorruptAside(file) === 'busy') return 'busy';
|
|
415
|
+
cur = {};
|
|
416
|
+
raw = null;
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
const next = mutate(cur);
|
|
420
|
+
if (next === null || next === undefined) return 'same';
|
|
421
|
+
const text = (o.serialize || ((v) => JSON.stringify(v, null, 2) + '\n'))(next);
|
|
422
|
+
if (raw !== null && text === raw) return 'same';
|
|
423
|
+
if (o.before) o.before(raw, cur);
|
|
424
|
+
writeAtomic(file, text, o.mode ?? 0o600);
|
|
425
|
+
return 'written';
|
|
426
|
+
} catch {
|
|
427
|
+
return 'failed';
|
|
428
|
+
} finally {
|
|
429
|
+
fileUnlock(h);
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
|
|
153
433
|
function credWrite(obj) {
|
|
154
434
|
fs.mkdirSync(path.dirname(CRED_FILE), { recursive: true, mode: 0o700 });
|
|
155
|
-
// **먼저 좁히고 쓴다.**
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
435
|
+
// **먼저 좁히고 쓴다.** 임시 파일을 0600 으로 만들어 채운 뒤 옮긴다(§5.2) — 열린 채로 쓰고
|
|
436
|
+
// 나중에 chmod 하면 그 사이에 읽힐 수 있고, 절단 쓰기 도중에 읽은 창은 자격을 잃는다.
|
|
437
|
+
const r = jsonFileUpdate(CRED_FILE, () => obj);
|
|
438
|
+
if (r !== 'written' && r !== 'same') throw new Error(`자격 파일을 쓰지 못했습니다(${r}): ${CRED_FILE}`);
|
|
439
|
+
fs.chmodSync(CRED_FILE, 0o600); // 값이 같아 안 썼으면 옛 파일의 권한이 남는다
|
|
159
440
|
}
|
|
160
441
|
|
|
161
442
|
/* ── 진행 중인 로그인 ────────────────────────────────────────────────────
|
|
@@ -171,14 +452,18 @@ const PENDING_FILE = path.join(path.dirname(CRED_FILE), 'pending.json');
|
|
|
171
452
|
|
|
172
453
|
function pendingWrite(obj) {
|
|
173
454
|
fs.mkdirSync(path.dirname(PENDING_FILE), { recursive: true, mode: 0o700 });
|
|
174
|
-
|
|
175
|
-
|
|
455
|
+
// 자격과 같은 길(§5.2) — 잠금 · 0600 임시 파일 · rename.
|
|
456
|
+
const r = jsonFileUpdate(PENDING_FILE, () => obj, { serialize: (v) => JSON.stringify(v) + '\n' });
|
|
457
|
+
if (r !== 'written' && r !== 'same') throw new Error(`진행 중인 로그인을 적지 못했습니다(${r}): ${PENDING_FILE}`);
|
|
176
458
|
fs.chmodSync(PENDING_FILE, 0o600);
|
|
177
459
|
}
|
|
178
460
|
function pendingRead() {
|
|
179
461
|
try { return JSON.parse(fs.readFileSync(PENDING_FILE, 'utf8')); } catch { return null; }
|
|
180
462
|
}
|
|
181
|
-
function pendingClear() {
|
|
463
|
+
function pendingClear() {
|
|
464
|
+
const h = fileLock(PENDING_FILE); // 쓰는 창과 엇갈려 방금 쓴 것을 지우지 않게
|
|
465
|
+
try { fs.rmSync(PENDING_FILE, { force: true }); } catch {} finally { fileUnlock(h); }
|
|
466
|
+
}
|
|
182
467
|
|
|
183
468
|
/** 이 기계에 심긴 사람 자격(없으면 null). */
|
|
184
469
|
function userCred() {
|
|
@@ -606,14 +891,28 @@ async function apiOpen(method, pathname, body) {
|
|
|
606
891
|
* 없던 일이 되지 않는다. stdout 은 프로토콜 전용이라 stderr 로만 쓴다.
|
|
607
892
|
*/
|
|
608
893
|
/*
|
|
609
|
-
* **발행 갈래의 표.** 이
|
|
894
|
+
* **발행 갈래의 표.** 이 셋만 키를 먼저 쥔다 — 오발행은 되돌려도 본 사람은 봤다.
|
|
895
|
+
* (공개·교체 둘은 아래 `RELEASE` 로 사람이 먼저다 — DESIGN-genetics-ssot.md §5.6.)
|
|
610
896
|
*
|
|
611
897
|
* ⚠ `remember_case` 에는 **붙이면 안 된다.** 서버가 `/api/wiki` 를 키에 대해 403 하므로
|
|
612
898
|
* (「지식은 사람 토큰(운영진)으로만 봅니다」) 붙이는 순간 그 도구가 죽는다.
|
|
613
899
|
* 「쓰기니까 발행 쪽」이라는 직관이 여기서 틀린다 — 가르는 것은 쓰기냐가 아니라
|
|
614
900
|
* **서버가 키를 받느냐**다.
|
|
615
901
|
*/
|
|
616
|
-
const PUBLISH = { scope: 'publish' };
|
|
902
|
+
const PUBLISH = { scope: 'publish' }; // publish_report·unpublish_report·report_assets — 키 먼저(그대로)
|
|
903
|
+
/*
|
|
904
|
+
* **공개와 교체는 사람이 먼저다**(DESIGN-genetics-ssot.md §5.6). 사람 자격으로 공개한 보고서만
|
|
905
|
+
* 열린 항목이 일감으로 선다(`released_by>0`). 사람 자격이 없으면 `credential()` 이 그대로 키로
|
|
906
|
+
* 떨어진다 — 거절이 아니라 서버 응답 note 가 그 사실을 말한다. `credential()` 몸통은 안 바꾼다:
|
|
907
|
+
* `'publish'` 가 아니면 이미 사람 먼저다. 바꾸는 것은 호출부 둘의 상수뿐이다.
|
|
908
|
+
*/
|
|
909
|
+
const RELEASE = { scope: 'release' }; // release_report·update_report — 사람 먼저
|
|
910
|
+
|
|
911
|
+
/*
|
|
912
|
+
* 서버 최소 클라이언트 판(DESIGN-genetics-ssot.md §5.8·§9.4) — 모든 /api/* 응답의 `X-Genetics-Min-Client`.
|
|
913
|
+
* `api()` 의 반환 계약(`r.data`)은 그대로 두고 **모듈 전역 하나만** 적는다. 판 비교·알림은 심박(`minClientTick`)이 한다.
|
|
914
|
+
*/
|
|
915
|
+
let LAST_MIN_CLIENT = '';
|
|
617
916
|
|
|
618
917
|
const warnSaid = new Set();
|
|
619
918
|
function warnOnce(key, msg) {
|
|
@@ -624,7 +923,9 @@ function warnOnce(key, msg) {
|
|
|
624
923
|
|
|
625
924
|
/**
|
|
626
925
|
* @param {object} [opts]
|
|
627
|
-
* @param {'publish'} [opts.scope] 발행 갈래면 `'publish'` — 키를 먼저 쥔다(credential 주석).
|
|
926
|
+
* @param {'publish'|'release'} [opts.scope] 발행 갈래면 `'publish'` — 키를 먼저 쥔다(credential 주석). `'release'` 는 사람 먼저(§5.6).
|
|
927
|
+
* @param {Record<string,string>} [opts.headers] 더 실을 머리(자동 올리기의 X-Genetics-* 셋 — §4.3)
|
|
928
|
+
* 실패는 던지는 Error 에 `status`·`code`·`retry_after` 가 붙는다(반환 계약 `r.data` 는 그대로).
|
|
628
929
|
*/
|
|
629
930
|
async function api(method, pathname, body, opts = {}) {
|
|
630
931
|
if (!BASE) throw new Error(`발행 주소가 없습니다. ${ENV_FILE} 에 GENETICS_API_BASE 를 넣으세요(예: https://api.example.com).`);
|
|
@@ -643,12 +944,14 @@ async function api(method, pathname, body, opts = {}) {
|
|
|
643
944
|
const send = async (c) => {
|
|
644
945
|
const res = await fetch(BASE + pathname, {
|
|
645
946
|
method,
|
|
646
|
-
headers
|
|
947
|
+
// opts.headers 는 자동 올리기의 머리 셋(§4.3)이 쓴다 — 자격·형식 머리는 덮지 못한다.
|
|
948
|
+
headers: { ...(opts.headers || {}), Authorization: `Bearer ${c.token}`, 'Content-Type': 'application/json' },
|
|
647
949
|
body: body === undefined ? undefined : JSON.stringify(body),
|
|
648
950
|
});
|
|
951
|
+
LAST_MIN_CLIENT = res.headers.get('x-genetics-min-client') || LAST_MIN_CLIENT; // §5.8 — 반환 계약은 그대로
|
|
649
952
|
const text = await res.text();
|
|
650
953
|
let data;
|
|
651
|
-
try { data = JSON.parse(text); } catch { throw new Error(`응답을 해석하지 못했습니다(HTTP ${res.status}): ${text.slice(0, 300)}`); }
|
|
954
|
+
try { data = JSON.parse(text); } catch { throw Object.assign(new Error(`응답을 해석하지 못했습니다(HTTP ${res.status}): ${text.slice(0, 300)}`), { status: res.status }); }
|
|
652
955
|
return { ok: res.ok && data.ok !== false, status: res.status, data };
|
|
653
956
|
};
|
|
654
957
|
|
|
@@ -679,7 +982,12 @@ async function api(method, pathname, body, opts = {}) {
|
|
|
679
982
|
warnOnce('cred-expired', '로그인 자격이 만료돼 키로 처리했습니다 — `login` 으로 다시 로그인하세요.');
|
|
680
983
|
return viaKey.data;
|
|
681
984
|
}
|
|
682
|
-
|
|
985
|
+
// 자동 올리기의 백오프(§5.5 7)가 상태·code·retry_after 로 갈린다 — 메시지는 그대로, 칸만 붙인다.
|
|
986
|
+
// `auto_upload_at` 은 409 `too_old`(관문 11)가 **서버의 동의 시각**을 알려 주는 칸이다 — 그것이 곧 클라이언트가
|
|
987
|
+
// 못 받은 하한이라, 받아 두면 같은 거절을 24시간마다 되풀이하지 않는다(개인정보 N3).
|
|
988
|
+
throw Object.assign(new Error(r.data.error || `HTTP ${r.status}`),
|
|
989
|
+
{ status: r.status, code: String(r.data.code || ''), retry_after: Number(r.data.retry_after) || 0,
|
|
990
|
+
auto_upload_at: typeof r.data.auto_upload_at === 'string' ? r.data.auto_upload_at : '' });
|
|
683
991
|
}
|
|
684
992
|
return r.data;
|
|
685
993
|
}
|
|
@@ -911,6 +1219,14 @@ const REPORT_DATA = {
|
|
|
911
1219
|
+ 'toggle{a,b} 를 주면 rows 는 value 대신 a·b 를 갖고 두 축을 `:checked` 로 갈아 낀다 '
|
|
912
1220
|
+ '(체크박스는 스크립트가 아니라 상태다). 그림 번호는 `flow`·`plates` 와 **한 셈**을 쓴다.\n'
|
|
913
1221
|
+ '데이터가 있을 때만 그려진다 — 없는 문구를 지어내지 말 것.\n'
|
|
1222
|
+
+ '· `charts[].kind:"series"`: {kind:"series", caption, unit?, points:[{x,y}], marks?:[{x,label}], summary?:[{label,value,note?}], note?} — '
|
|
1223
|
+
+ '**시계열.** 하루·달 단위 값이 언제 계단을 만드는지 인라인 SVG 선으로 보이고, 달마다 평균·최대를 글자 행으로 남긴다(그림이 안 보여도 문서가 선다). '
|
|
1224
|
+
+ 'x 는 YYYY-MM-DD 가 기본. marks[].x 는 어느 points[].x 와 같은 값이어야 선다(사건 세로선, 아니면 ⚠).\n'
|
|
1225
|
+
+ '· `quotes`: [{text, lang?, source_text, source_url?, verdict?, translation?}] — **원문 인용.** 공식 문서·회신 문구를 원문 그대로 싣는다(표기 안 품, 줄바꿈 유지). '
|
|
1226
|
+
+ 'source_text·source_url 둘 중 하나는 있어야 한다(없으면 ⚠). verdict 는 도판과 같은 축(직접 확인·조사 보고·미확인). 절 본문 맨 앞에 선다.\n'
|
|
1227
|
+
+ '· `diagrams`: [{id?, caption, ir?, svg?, alt?, source_text?, verdict?, note?, wide?, origin?}] — **도해.** 기본은 `ir`: Archify 가 검증한 **workflow IR 그대로**(lanes·phases·nodes·edges·mainPath)를 넣으면 관보가 자기 활자(15px)·장 색으로 그린다. 다른 종류는 `svg`: 스킬 `assets/diagram.mjs` 로 굳힌 SVG 문자열 '
|
|
1228
|
+
+ '(서버가 스크립트·스타일·외부 참조를 다시 걷는다). ir 에서 읽는 칸: diagram_type(workflow) · lanes[].id·label · phases[].label·fromCol·toCol · nodes[].id·lane·col·type·label·sublabel·variant · edges[].from·to·label·variant · mainPath. 그림 번호는 차트·흐름도·도판과 한 셈. 글자가 표시 15px 미만이면 ⚠ — 넓은 도해는 wide:true(전폭 1030px). 상한 250,000B. 절차는 스킬 references/diagrams.md.\n'
|
|
1229
|
+
+ '· `flow.mode:"cause"`: 원인 사슬 — 첫 칸의 「해야 할 것」 강조 면을 걷어 「사실 → 우리 상태 → 결과」가 같은 무게로 선다.\n'
|
|
914
1230
|
+ '\n'
|
|
915
1231
|
+ '관보 문법이 절마다 요구하는 칸:\n'
|
|
916
1232
|
+ '· `part`: 이 절이 속한 PART 의 no. 안 주면 첫 묶음으로 떨어진다.\n'
|
|
@@ -1262,6 +1578,7 @@ function cwdMode(counts) {
|
|
|
1262
1578
|
function scrubTranscript(raw) {
|
|
1263
1579
|
const out = [];
|
|
1264
1580
|
const cwds = new Map();
|
|
1581
|
+
const cwdsRaw = new Map(); // 폴더 identity 는 realpath 뒤에 잰다(§3.2) — 이 기계 안에서만 쓰고 보내지 않는다
|
|
1265
1582
|
let redacted = 0, dropped = 0, no = 0;
|
|
1266
1583
|
for (const line of raw.split('\n')) {
|
|
1267
1584
|
no++;
|
|
@@ -1279,6 +1596,7 @@ function scrubTranscript(raw) {
|
|
|
1279
1596
|
if (typeof r.cwd === 'string' && r.cwd) {
|
|
1280
1597
|
const k = relHome(r.cwd);
|
|
1281
1598
|
cwds.set(k, (cwds.get(k) || 0) + 1);
|
|
1599
|
+
cwdsRaw.set(r.cwd, (cwdsRaw.get(r.cwd) || 0) + 1);
|
|
1282
1600
|
}
|
|
1283
1601
|
if (DROP_TYPES.has(r.type) && !isKeptAttachment(r)) { dropped++; continue; }
|
|
1284
1602
|
if (r.type === 'attachment') r.attachment.content = maskHomeInPick(r.attachment.content); // 남긴 픽 줄뿐이다(위 술어)
|
|
@@ -1331,7 +1649,10 @@ function scrubTranscript(raw) {
|
|
|
1331
1649
|
out.push(JSON.stringify(safe));
|
|
1332
1650
|
}
|
|
1333
1651
|
if (!out.length) throw new Error('빈 트랜스크립트입니다.');
|
|
1334
|
-
|
|
1652
|
+
// `cwds_raw` 는 **이 대화가 거쳐 간 자리 전부**다(빈도순). 최빈 자리 하나만 보면 세션의 일부가
|
|
1653
|
+
// `.genetics-private` 폴더에서 돌아도 그 대화가 통째로 올라간다(개인정보 검토 m1).
|
|
1654
|
+
const rawAll = [...cwdsRaw.entries()].sort((a, b) => b[1] - a[1]).map(([k]) => k).slice(0, 64);
|
|
1655
|
+
return { jsonl: out.join('\n') + '\n', lines: out.length, dropped, redacted, cwd: cwdMode(cwds), cwd_raw: cwdMode(cwdsRaw), cwds_raw: rawAll };
|
|
1335
1656
|
}
|
|
1336
1657
|
|
|
1337
1658
|
/** cwd → 트랜스크립트 디렉터리. **해석된 경로**로 슬러그를 만든다(심볼릭 링크 함정). */
|
|
@@ -1575,11 +1896,18 @@ function toolsStamp() {
|
|
|
1575
1896
|
return t;
|
|
1576
1897
|
}
|
|
1577
1898
|
|
|
1578
|
-
|
|
1899
|
+
/**
|
|
1900
|
+
* @param {'propose'|'skip'} [bind] `'propose'` 면 이 대화의 자리 폴더를 그 소속처에 **제안**한다(§4.2 —
|
|
1901
|
+
* 확정·자동 올리기는 포털에서 사람이 한다). 사람이 부른 push_session 만 제안하고, 심박의 갱신은 `'skip'`.
|
|
1902
|
+
*/
|
|
1903
|
+
async function pushSession(tenant, uuid, title, dryRun, bind = 'skip', auto = null) {
|
|
1579
1904
|
const f = findTranscript(uuid);
|
|
1580
1905
|
if (!f) throw new Error(`이 기계에 그 세션이 없습니다: ${uuid}`);
|
|
1581
|
-
|
|
1582
|
-
const
|
|
1906
|
+
// 검열한 **바로 그 바이트**의 지문(§5.1 raw_sha) — 파일을 두 번 읽으면 그 사이에 자란 줄이 섞인다.
|
|
1907
|
+
const bytes = fs.readFileSync(f);
|
|
1908
|
+
const rawSha = crypto.createHash('sha256').update(bytes).digest('hex');
|
|
1909
|
+
const sc = scrubTranscript(bytes.toString('utf8'));
|
|
1910
|
+
const before = bytes.length;
|
|
1583
1911
|
/*
|
|
1584
1912
|
* ★ **제목을 안 정했으면 키를 아예 뺀다.** 예전에는 `title: title || ''` 로 빈 문자열을
|
|
1585
1913
|
* **명시해서** 보냈고 서버가 그것으로 기존 제목을 덮었다. 자동 올리기는 제목 없이 부르므로
|
|
@@ -1616,6 +1944,41 @@ async function pushSession(tenant, uuid, title, dryRun) {
|
|
|
1616
1944
|
};
|
|
1617
1945
|
if (typeof title === 'string' && title.trim() !== '') body.title = title.trim();
|
|
1618
1946
|
if (dryRun) body.dry_run = true;
|
|
1947
|
+
/*
|
|
1948
|
+
* 폴더 제안(§4.2) — 이미 **정규화된 identity 와 마스킹 경로만** 싣는다. 원격 주소에는 자격이
|
|
1949
|
+
* 들어 있을 수 있어 원문을 안 보낸다. 자리는 origin_cwd 와 같은 차례로 고른다(본문 최빈 cwd →
|
|
1950
|
+
* Cursor 창 폴더 → 이 프로세스). 집·임시 폴더이거나 identity 를 못 만들면 folder 를 안 싣는다.
|
|
1951
|
+
*/
|
|
1952
|
+
body.bind = bind === 'propose' ? 'propose' : 'skip';
|
|
1953
|
+
if (body.bind === 'propose') {
|
|
1954
|
+
const place = sc.cwd_raw || (isCursorBody ? (cursorWorkspaceFolder() || '') : process.cwd());
|
|
1955
|
+
const folder = place ? folderForPush(folderOf(place)) : null;
|
|
1956
|
+
if (folder) body.folder = folder;
|
|
1957
|
+
}
|
|
1958
|
+
/*
|
|
1959
|
+
* 확정 폴더의 자동 올리기(§4.3·§5.5) — 사람이 도구를 부른 적이 없는 업로드다. 머리 셋을 붙이고, 보내기 **전에**
|
|
1960
|
+
* 이 기계가 알 수 있는 거절(검열본 32MB · 본문의 자리가 그 폴더 밖 · 그 자리의 .genetics-private)을 먼저 건다.
|
|
1961
|
+
* 서버도 관문 5·6 을 다시 잰다 — 여기서 거르는 것은 헛걸음을 줄이는 것이지 방어가 아니다.
|
|
1962
|
+
*/
|
|
1963
|
+
/*
|
|
1964
|
+
* ★ **사람이 올린다는 표는 머리다**(호환 검토 N2 · 부록 D-7). 서버는 「사람의 올리기」를 둘 중 하나로 읽는데
|
|
1965
|
+
* (`api.php:1695-1696`), **정본은 `X-Genetics-Push: human`** 이고 `bind:'propose'` 는 대리 표지다.
|
|
1966
|
+
* 그런데 이 클라이언트는 그 머리를 **자동 갈래에만** 붙이고 있어서, 정본을 보내는 클라이언트가 이 세상에
|
|
1967
|
+
* 하나도 없었다 — 대리 표지가 바뀌는 날 사람의 push_session 이 통째로 막힌다.
|
|
1968
|
+
* 사람의 올리기는 `push_session` 도구 하나뿐이고 그 갈래만 `bind:'propose'` 로 온다(심박의 갱신은 `'skip'`).
|
|
1969
|
+
*/
|
|
1970
|
+
let headers;
|
|
1971
|
+
if (!auto && body.bind === 'propose') headers = { 'X-Genetics-Push': 'human' };
|
|
1972
|
+
if (auto) {
|
|
1973
|
+
const prep = Buffer.byteLength(sc.jsonl, 'utf8') > AUTO_BODY_MAX ? { code: 'too_big' } : auto.prepare(sc);
|
|
1974
|
+
if (prep.code) {
|
|
1975
|
+
throw Object.assign(new Error(`자동 올리기를 보류했다(${prep.code})`),
|
|
1976
|
+
{ code: prep.code, status: prep.code === 'place_unreadable' ? 422 : 0 }); // 서버가 같은 이유로 내는 422 와 같은 무게
|
|
1977
|
+
}
|
|
1978
|
+
body.folder = prep.folder;
|
|
1979
|
+
headers = { 'X-Genetics-Binding': String(auto.binding_id), 'X-Genetics-Device': auto.device,
|
|
1980
|
+
'X-Genetics-Client': `genetics-mcp/${VERSION}` };
|
|
1981
|
+
}
|
|
1619
1982
|
|
|
1620
1983
|
/*
|
|
1621
1984
|
* ── 긴 대화는 **조각으로** 보낸다 ─────────────────────────────────
|
|
@@ -1634,11 +1997,30 @@ async function pushSession(tenant, uuid, title, dryRun) {
|
|
|
1634
1997
|
*/
|
|
1635
1998
|
const parts = splitAtLines(sc.jsonl, PUSH_PART_MAX);
|
|
1636
1999
|
if (parts.length === 1) {
|
|
1637
|
-
const r = await api('PUT', '/api/sessions', body);
|
|
1638
|
-
|
|
1639
|
-
|
|
2000
|
+
const r = await api('PUT', '/api/sessions', body, { headers });
|
|
2001
|
+
// `auto` — 서버가 받았으므로 서버발 막음 표(too_old·hidden·hold)를 지운다. 사람의 손 올리기가 실제로 푸는 자리다(호환 C2).
|
|
2002
|
+
// hidden·hold 표는 **받은 소속처가 그 표의 소속처일 때만** 지운다(호환 N9 — `autoClearOnPush()`).
|
|
2003
|
+
if (!dryRun) indexNote(uuid, { src: 'push', tenant: r.tenant || tenant, tenant_id: r.tenant_id, head_id: r.head_id, head_ms: r.head_ms, raw_sha: rawSha, auto: autoClearOnPush(r.tenant_id), ...(r.tenant_slug ? { tenant_slug: r.tenant_slug } : {}) }, 'tx');
|
|
2004
|
+
return { r, before, parts: 1, raw_sha: rawSha };
|
|
1640
2005
|
}
|
|
1641
2006
|
|
|
2007
|
+
/*
|
|
2008
|
+
* ★★ **조각으로 가는 자동 올리기는 봉투마다 마지막 발화 시각(`head_ms`)을 싣는다**(개인정보 N3 잔여 A · §4.3 관문 11).
|
|
2009
|
+
* 서버는 조각 길에서 소급 금지를 **첫 조각이 디스크에 닿기 전에** 재는데, 마지막 발화는 마지막 조각에 있다 —
|
|
2010
|
+
* 그래서 부르는 쪽이 미리 적어야 하고, 없으면 조각마다 409 `head_ms_required` 다(8MB 넘는 대화의 자동 올리기가 안 선다).
|
|
2011
|
+
* 값은 §5.5 거르개와 **같은 규칙**(`jsonlHead()` — `transcriptHead()` 가 쓰는 그 한 벌)으로, **지금 보내는 바로 그
|
|
2012
|
+
* 검열본**에서 잰다. 거르개가 파일 꼬리로 잰 값(`c.head_ms`)을 넘기지 않는 이유 둘: ① 거른 뒤 읽기 전에 전사가 자라면
|
|
2013
|
+
* 선언이 보내는 본문과 갈린다, ② 파일 꼬리 256KB 가 거대한 한 줄(도구 결과·첨부) 속이면 꼬리로는 0 이다 — 보내는 글은
|
|
2014
|
+
* 이미 메모리에 통째로 있으니 끝까지 거슬러 잰다. 조각 길로 오는 대화가 바로 그런 큰 대화다.
|
|
2015
|
+
* ⚠ 선언은 재는 것을 대신하지 않는다 — 서버가 마지막 조각에서 이어 붙인 본문으로 다시 잰다.
|
|
2016
|
+
* ⚠ 사람의 `push_session` 에는 안 싣는다(그 길에는 이 관문이 없다 — 봉투 모양을 안 바꾼다).
|
|
2017
|
+
* ⚠ 잴 수 없으면(id 가 선 발화가 없거나 그 발화에 시각이 없다) **보내지 않는다**: 서버가 같은 이유로 409 를 낼 자리라,
|
|
2018
|
+
* 보내면 첫 조각이 날마다 그 소속처로 가서 거절만 받는다(개인정보 NEW-4 와 같은 병). 무게는 그 409 와 같게 둔다.
|
|
2019
|
+
*/
|
|
2020
|
+
const autoHeadMs = auto ? jsonlHead(sc.jsonl).ms : 0;
|
|
2021
|
+
if (auto && !(autoHeadMs > 0)) {
|
|
2022
|
+
throw Object.assign(new Error('자동 올리기를 보류했다(head_ms_required)'), { code: 'head_ms_required', status: 409 });
|
|
2023
|
+
}
|
|
1642
2024
|
const uploadId = crypto.randomBytes(12).toString('hex');
|
|
1643
2025
|
let last = null;
|
|
1644
2026
|
for (let i = 0; i < parts.length; i++) {
|
|
@@ -1647,13 +2029,15 @@ async function pushSession(tenant, uuid, title, dryRun) {
|
|
|
1647
2029
|
last = await api('PUT', '/api/sessions', {
|
|
1648
2030
|
...body, jsonl: parts[i],
|
|
1649
2031
|
upload_id: uploadId, part: i + 1, parts: parts.length,
|
|
1650
|
-
|
|
2032
|
+
...(auto ? { head_ms: autoHeadMs } : {}), // 관문 11 은 조각마다 선다 — 첫 조각에만 실으면 둘째부터 409 다
|
|
2033
|
+
}, { headers });
|
|
1651
2034
|
// 바이트로 적는다 — `.length` 는 UTF-16 단위라 한글이 3배로 어긋난다(splitAtLines 의 그 함정).
|
|
1652
2035
|
process.stderr.write(`genetics: 조각 ${i + 1}/${parts.length} ${dryRun ? '시험' : '올림'} `
|
|
1653
2036
|
+ `(${(Buffer.byteLength(parts[i], 'utf8') / 1048576).toFixed(1)}MB)\n`);
|
|
1654
2037
|
}
|
|
1655
|
-
|
|
1656
|
-
|
|
2038
|
+
// 〃 조각 올리기도 같다 — 마지막 조각까지 받았으면 그 uuid 에 대한 막음은 지금 안 선다(호환 C2 · 소속처 견줌은 N9).
|
|
2039
|
+
if (!dryRun) indexNote(uuid, { src: 'push', tenant: last.tenant || tenant, tenant_id: last.tenant_id, head_id: last.head_id, head_ms: last.head_ms, raw_sha: rawSha, auto: autoClearOnPush(last.tenant_id), ...(last.tenant_slug ? { tenant_slug: last.tenant_slug } : {}) }, 'tx');
|
|
2040
|
+
return { r: last, before, parts: parts.length, raw_sha: rawSha };
|
|
1657
2041
|
}
|
|
1658
2042
|
|
|
1659
2043
|
/* ── 코로케이션 심박 ───────────────────────────────────────────────────
|
|
@@ -1680,6 +2064,7 @@ const BEAT = { MAGIC: 'LMB1', REPLY: 'LMBR', VER: 1, HEAD: 8, REC: 56, RREC: 32,
|
|
|
1680
2064
|
const BEAT_STATE = { OPEN: 0, WORKING: 1, CLOSED: 2 };
|
|
1681
2065
|
const BEAT_VERDICT = { SAME: 0, AHEAD: 1, BEHIND: 2, UNKNOWN: 3 };
|
|
1682
2066
|
const BEAT_WANT_PUSH = 0x01;
|
|
2067
|
+
const BEAT_MOVED = 0x02; // agent_beat.php AGENT_BEAT_F_MOVED — 오면 resolve 를 다시 부른다(§5.3)
|
|
1683
2068
|
|
|
1684
2069
|
/** 심박 간격. 20초 = 서버가 죽었다고 보는 90초 안에 세 번 놓칠 여유. */
|
|
1685
2070
|
const BEAT_EVERY_MS = 20_000;
|
|
@@ -1756,9 +2141,13 @@ function deviceId() {
|
|
|
1756
2141
|
*/
|
|
1757
2142
|
const INDEX_FILE = path.join(path.dirname(CRED_FILE), 'sessions.json');
|
|
1758
2143
|
|
|
2144
|
+
/*
|
|
2145
|
+
* ⚠ **못 읽었다를 「없다」로 읽지 않는다**(호환 검토 H2). 옛 판의 절단 창에 걸리면 전에는 `{}` 를 냈고,
|
|
2146
|
+
* 그 빈 색인이 「이 대화는 서버에 올라간 적 없다」로 읽혀 사람이 가린 대화를 되살릴 뻔했다(개인정보 B1).
|
|
2147
|
+
* 잠금 밖 읽기도 `jsonReadRetry` 로 세 번 더 본다 — 옛 판의 쓰기는 그 안에 끝난다.
|
|
2148
|
+
*/
|
|
1759
2149
|
function indexRead() {
|
|
1760
|
-
|
|
1761
|
-
catch { return {}; }
|
|
2150
|
+
return jsonReadRetry(INDEX_FILE).obj || {};
|
|
1762
2151
|
}
|
|
1763
2152
|
/**
|
|
1764
2153
|
* 색인 한 줄을 적는다. `flow` 는 **이 쓰기가 무엇 때문인가**다 — 'tx'(내가 밀었다) ·
|
|
@@ -1775,17 +2164,136 @@ function tenantArgOf(e) {
|
|
|
1775
2164
|
return (e && typeof e.tenant_slug === 'string' && e.tenant_slug !== '') ? e.tenant_slug : (e?.tenant || '');
|
|
1776
2165
|
}
|
|
1777
2166
|
|
|
2167
|
+
/*
|
|
2168
|
+
* ── sessions.json v2 (DESIGN-genetics-ssot.md §5.1·§5.2) ──────────────────────
|
|
2169
|
+
*
|
|
2170
|
+
* **판정의 답이 아니라 세션의 메모다.** 폴더 층의 답은 resolve.json 하나라 여기에는
|
|
2171
|
+
* `binding_id`·`auto_upload` 를 두지 않는다. 이 파일의 소속 칸은 「그 세션이 서버 어디에 사는가」이고
|
|
2172
|
+
* 그 칸을 쓰는 길은 `indexTenant`(서버가 준 값만) 하나다. 커서·머리·백오프는 `indexPatch` 로만
|
|
2173
|
+
* 쓴다 — 소속 칸을 못 건드린다(P1-a). `indexNote` 는 부르는 자리들을 위한 얇은 껍데기로 남긴다.
|
|
2174
|
+
*
|
|
2175
|
+
* 쓰기는 전부 잠금 안(jsonFileUpdate)이다. ⚠ 잠금 없는 옛 판이 섞이면 남의 행은 못 지킨다 —
|
|
2176
|
+
* 그래서 **이 프로세스가 적은 행만** 메모리 사본에서 되세운다(자가 복구). 남의 행은 그 세션이
|
|
2177
|
+
* 다음 resolve·업로드에서 스스로 다시 적는다. 시각 칸(`*_at`)은 epoch 밀리초다.
|
|
2178
|
+
*/
|
|
2179
|
+
const INDEX_TENANT_KEYS = ['tenant_id', 'tenant_slug', 'tenant', 'resolved_at', 'src'];
|
|
2180
|
+
const INDEX_SRC = ['push', 'list', 'resume', 'pull', 'mail', 'resolve', 'moved'];
|
|
2181
|
+
const indexMine = new Map(); // uuid → 이 프로세스가 마지막으로 적은 행
|
|
2182
|
+
|
|
2183
|
+
/** 적은 시각(seen·resolved_at)만 다른 두 행은 같은 값이다 — 같으면 안 쓴다(§5.2). */
|
|
2184
|
+
function indexRowSame(a, b) {
|
|
2185
|
+
const strip = (r) => { const { seen, resolved_at, ...rest } = r || {}; return JSON.stringify(rest); };
|
|
2186
|
+
return strip(a) === strip(b);
|
|
2187
|
+
}
|
|
2188
|
+
|
|
2189
|
+
/**
|
|
2190
|
+
* @param {string} uuid
|
|
2191
|
+
* @param {null|{tenant_id:number, tenant_slug?:string, tenant?:string, src?:string}} tenant 서버가 준 소속. null 이면 소속 칸을 안 건드린다
|
|
2192
|
+
* @param {object} patch 소속 밖 칸 — 소속 칸이 섞여 오면 버린다
|
|
2193
|
+
* @param {''|'tx'|'rx'} flow
|
|
2194
|
+
* @param {null|((row: object|null) => boolean)} [guard] 잠금 안에서 파일의 그 행을 보고 거짓이면 안 쓴다(자동 올리기의 선점 — §5.5)
|
|
2195
|
+
*/
|
|
2196
|
+
function indexWrite(uuid, tenant, patch, flow, guard = null) {
|
|
2197
|
+
if (typeof uuid !== 'string' || uuid === '' || uuid === '_v') return false;
|
|
2198
|
+
const p = {};
|
|
2199
|
+
for (const [k, v] of Object.entries(patch || {})) {
|
|
2200
|
+
if (k === '_v' || INDEX_TENANT_KEYS.includes(k) || v === undefined) continue;
|
|
2201
|
+
p[k] = v;
|
|
2202
|
+
}
|
|
2203
|
+
const now = Date.now();
|
|
2204
|
+
if (flow === 'tx') p.tx_at = now;
|
|
2205
|
+
if (flow === 'rx') p.rx_at = now;
|
|
2206
|
+
// (판 8 — 적합성 F7) 성공한 올리기는 **그 소속처의** 곁 표도 걷는다 — 파일의 표와 같은 견줌(`autoBlockClearedBy()`).
|
|
2207
|
+
if (p.auto && typeof p.auto === 'object' && Object.hasOwn(p.auto, AUTO_UNBLOCK_KEY)) {
|
|
2208
|
+
const a = autoBlockAside.get(uuid.toLowerCase());
|
|
2209
|
+
if (a && autoBlockClearedBy(a, p.auto[AUTO_UNBLOCK_KEY])) autoBlockAside.delete(uuid.toLowerCase());
|
|
2210
|
+
}
|
|
2211
|
+
const r = jsonFileUpdate(INDEX_FILE, (cur) => {
|
|
2212
|
+
const idx = { _v: 2, ...cur };
|
|
2213
|
+
idx._v = 2; // 옛 판은 이 줄을 e?.tenant_id 로 걸러 무시한다
|
|
2214
|
+
const had = !!(idx[uuid] && typeof idx[uuid] === 'object');
|
|
2215
|
+
// 파일에서 사라진 내 행이면(잠금 없는 옛 판의 절단·못 읽은 파일) 메모리 사본에서 시작한다 —
|
|
2216
|
+
// patch 만 남기면 그 행의 나머지 칸(머리·소속)을 잃고, 그 반쪽 행이 메모리 사본까지 덮는다.
|
|
2217
|
+
const was = had ? idx[uuid] : (indexMine.get(uuid) || {});
|
|
2218
|
+
if (guard && !guard(had ? idx[uuid] : null)) return null;
|
|
2219
|
+
let row = { ...was, ...p };
|
|
2220
|
+
// `auto`(백오프·보류, §5.1)는 칸끼리 합친다 — 백오프만 고치는 쓰기가 사람이 세운 hold 를 지우면 안 된다.
|
|
2221
|
+
if (p.auto && typeof p.auto === 'object') row.auto = { ...(was.auto && typeof was.auto === 'object' ? was.auto : {}), ...p.auto };
|
|
2222
|
+
// 호환 N9 — 막음 표(`auto.blocked`)는 **받은 소속처가 그 표의 소속처일 때만** 지운다. 판정은 잠금 안에서,
|
|
2223
|
+
// 파일에 지금 있는 표로 한다(잠금 밖에서 읽은 표로 가르면 그 사이 옆 창이 세운 표를 못 본다). 그 칸은 행에 안 남긴다.
|
|
2224
|
+
if (p.auto && typeof p.auto === 'object' && Object.hasOwn(p.auto, AUTO_UNBLOCK_KEY)) {
|
|
2225
|
+
const to = row.auto[AUTO_UNBLOCK_KEY];
|
|
2226
|
+
delete row.auto[AUTO_UNBLOCK_KEY];
|
|
2227
|
+
if (autoBlockClearedBy(row.auto.blocked, to)) row.auto.blocked = null;
|
|
2228
|
+
}
|
|
2229
|
+
if (flow === 'tx' && !row.origin) row.origin = 'local';
|
|
2230
|
+
if (tenant) {
|
|
2231
|
+
const same = Number(was.tenant_id) === Number(tenant.tenant_id);
|
|
2232
|
+
row.tenant_id = Number(tenant.tenant_id);
|
|
2233
|
+
if (tenant.tenant_slug) row.tenant_slug = String(tenant.tenant_slug);
|
|
2234
|
+
else if (!same) delete row.tenant_slug; // 다른 곳으로 바뀌었는데 slug 를 모르면 옛 slug 를 짝으로 안 남긴다
|
|
2235
|
+
if (tenant.tenant) row.tenant = String(tenant.tenant);
|
|
2236
|
+
if (INDEX_SRC.includes(tenant.src)) row.src = tenant.src;
|
|
2237
|
+
row.resolved_at = now;
|
|
2238
|
+
}
|
|
2239
|
+
if (had && indexRowSame(row, was)) row = was;
|
|
2240
|
+
else row.seen = new Date(now).toISOString();
|
|
2241
|
+
idx[uuid] = row;
|
|
2242
|
+
indexMine.set(uuid, row);
|
|
2243
|
+
for (const [u, mineRow] of indexMine) {
|
|
2244
|
+
if (!idx[u] || typeof idx[u] !== 'object') idx[u] = mineRow; // 자가 복구 — 내 행만
|
|
2245
|
+
}
|
|
2246
|
+
return idx;
|
|
2247
|
+
}, {
|
|
2248
|
+
// 첫 v2 쓰기 전에 옛 파일을 한 벌 떠 둔다(되돌리기 자리 — §13.3).
|
|
2249
|
+
before: (raw, cur) => {
|
|
2250
|
+
if (raw === null || cur?._v === 2) return;
|
|
2251
|
+
try {
|
|
2252
|
+
// **한 벌만** — 잠금 없는 옛 판이 읽기에 실패해 `_v` 없이 덮어도 사본이 쌓이지 않게.
|
|
2253
|
+
const stem = `${path.basename(INDEX_FILE)}.bak-v2-`;
|
|
2254
|
+
if (fs.readdirSync(path.dirname(INDEX_FILE)).some((n) => n.startsWith(stem))) return;
|
|
2255
|
+
fs.writeFileSync(`${INDEX_FILE}.bak-v2-${isoStamp()}`, raw, { mode: 0o600, flag: 'wx' });
|
|
2256
|
+
} catch { /* */ }
|
|
2257
|
+
},
|
|
2258
|
+
});
|
|
2259
|
+
return r === 'written' || r === 'same';
|
|
2260
|
+
}
|
|
2261
|
+
|
|
2262
|
+
/** 소속 밖 칸만 적는다(커서·머리·굽기·백오프·보류·origin). 소속 칸을 주면 버린다. */
|
|
2263
|
+
function indexPatch(uuid, patch, flow = '') {
|
|
2264
|
+
try { return indexWrite(uuid, null, patch, flow); } catch { return false; }
|
|
2265
|
+
}
|
|
2266
|
+
|
|
2267
|
+
/** 소속 칸을 적는다 — **서버가 준 값만**(resolve·push·list·pull·resume·mail 응답, moved 처리). */
|
|
2268
|
+
function indexTenant(uuid, t) {
|
|
2269
|
+
if (!t || !(Number(t.tenant_id) > 0)) return false;
|
|
2270
|
+
try { return indexWrite(uuid, t, {}, ''); } catch { return false; }
|
|
2271
|
+
}
|
|
2272
|
+
|
|
2273
|
+
/** 옛 부름 자리의 껍데기 — 소속 칸(서버 응답)과 나머지를 갈라 **한 번의 잠금으로** 적는다. */
|
|
1778
2274
|
function indexNote(uuid, entry, flow = '') {
|
|
1779
2275
|
if (!uuid || !entry?.tenant_id) return;
|
|
2276
|
+
const { tenant_id, tenant_slug, tenant, src, resolved_at, ...rest } = entry;
|
|
2277
|
+
try { indexWrite(uuid, { tenant_id, tenant_slug, tenant, src }, rest, flow); }
|
|
2278
|
+
catch { /* 색인은 있으면 좋은 것이지 도구가 실패할 이유가 아니다 */ }
|
|
2279
|
+
}
|
|
2280
|
+
|
|
2281
|
+
/**
|
|
2282
|
+
* 자동 올리기의 **선점**(§5.5) — 이 기계의 창 여럿이 같은 후보를 한꺼번에 올리지 않게, 잠금 안에서 그 행의
|
|
2283
|
+
* `auto.next_at`(다음 시도 시각)을 보고 비어 있거나 지났을 때만 `until` 로 세운다. 보류(hold)면 안 잡는다.
|
|
2284
|
+
* 올리다 죽어도 선점은 `until` 에 저절로 풀린다 — 새 칸을 만들지 않고 백오프 칸 하나로 한다.
|
|
2285
|
+
*/
|
|
2286
|
+
function indexClaim(uuid, until, now = Date.now()) {
|
|
2287
|
+
let got = false;
|
|
2288
|
+
let ok = false;
|
|
1780
2289
|
try {
|
|
1781
|
-
|
|
1782
|
-
|
|
1783
|
-
|
|
1784
|
-
|
|
1785
|
-
|
|
1786
|
-
|
|
1787
|
-
|
|
1788
|
-
} catch { /* 색인은 있으면 좋은 것이지 도구가 실패할 이유가 아니다 */ }
|
|
2290
|
+
ok = indexWrite(uuid, null, { auto: { next_at: until } }, '', (row) => {
|
|
2291
|
+
const a = row?.auto;
|
|
2292
|
+
got = !(a?.hold === true || Number(a?.next_at) > now);
|
|
2293
|
+
return got;
|
|
2294
|
+
});
|
|
2295
|
+
} catch { return false; }
|
|
2296
|
+
return got && ok;
|
|
1789
2297
|
}
|
|
1790
2298
|
|
|
1791
2299
|
/**
|
|
@@ -1812,20 +2320,9 @@ function transcriptHead(file) {
|
|
|
1812
2320
|
fs.readSync(fd, tail, 0, tail.length, from);
|
|
1813
2321
|
const text = tail.toString('utf8');
|
|
1814
2322
|
// 앞이 잘린 줄은 버린다 — 반쪽 JSON 은 파싱이 안 되지만, 버리는 것을 **의도로** 적어 둔다.
|
|
1815
|
-
|
|
1816
|
-
|
|
1817
|
-
|
|
1818
|
-
let head = { id: '', ms: 0 };
|
|
1819
|
-
for (let i = lines.length - 1; i >= 0; i--) {
|
|
1820
|
-
const s = lines[i].trim();
|
|
1821
|
-
if (!s) continue;
|
|
1822
|
-
let r; try { r = JSON.parse(s); } catch { continue; }
|
|
1823
|
-
if (r?.type !== 'user' && r?.type !== 'assistant') continue;
|
|
1824
|
-
const hex = String(r.uuid || '').replace(/-/g, '').toLowerCase();
|
|
1825
|
-
if (!/^[0-9a-f]{32}$/.test(hex)) continue;
|
|
1826
|
-
head = { id: hex.slice(0, 16), ms: Date.parse(r.timestamp || '') || 0 };
|
|
1827
|
-
break;
|
|
1828
|
-
}
|
|
2323
|
+
// (개행이 하나도 없으면 꼬리 전체가 잘린 한 줄이다 — 남는 것이 없다.)
|
|
2324
|
+
const cut = from > 0 ? text.indexOf('\n') : -1;
|
|
2325
|
+
const head = jsonlHead(from > 0 ? (cut < 0 ? '' : text.slice(cut + 1)) : text);
|
|
1829
2326
|
|
|
1830
2327
|
// 줄 수 — 늘어난 만큼만 센다.
|
|
1831
2328
|
let lineCount;
|
|
@@ -1843,6 +2340,32 @@ function transcriptHead(file) {
|
|
|
1843
2340
|
return head;
|
|
1844
2341
|
} catch { return null; } finally { fs.closeSync(fd); }
|
|
1845
2342
|
}
|
|
2343
|
+
/**
|
|
2344
|
+
* 글(jsonl) 하나의 **머리** — 뒤에서부터 훑어 처음 만나는 발화(user·assistant, id 32hex)의 id 앞 16hex 와 시각.
|
|
2345
|
+
*
|
|
2346
|
+
* ★ **머리를 재는 규칙은 이 한 벌이다.** `transcriptHead()`(파일 꼬리 — 심박·§5.5 거르개)와 조각 자동 올리기의
|
|
2347
|
+
* 봉투 `head_ms`(`pushSession()` — 지금 보내는 검열본)가 둘 다 이것을 부른다. 두 벌이면 클라이언트가 거른 값과
|
|
2348
|
+
* 서버에 선언한 값이 갈리는 날이 온다. PHP `agent_session_head()` 와 같은 값을 낸다.
|
|
2349
|
+
* ⚠ `split('\n')` 로 펴지 않는다 — 조각 길의 검열본은 수십 MB 다. 뒤에서 줄 하나씩만 자른다(PHP 쪽과 같은 이유).
|
|
2350
|
+
*
|
|
2351
|
+
* @return {{id: string, ms: number}} 못 찾으면 id='' · ms=0
|
|
2352
|
+
*/
|
|
2353
|
+
function jsonlHead(text) {
|
|
2354
|
+
const s = String(text || '');
|
|
2355
|
+
let end = s.length;
|
|
2356
|
+
while (end > 0) {
|
|
2357
|
+
const nl = s.lastIndexOf('\n', end - 1);
|
|
2358
|
+
const line = s.slice(nl + 1, end).trim();
|
|
2359
|
+
end = nl < 0 ? 0 : nl; // 다음 바퀴는 이 개행 앞까지
|
|
2360
|
+
if (!line) continue;
|
|
2361
|
+
let r; try { r = JSON.parse(line); } catch { continue; }
|
|
2362
|
+
if (r?.type !== 'user' && r?.type !== 'assistant') continue;
|
|
2363
|
+
const hex = String(r.uuid || '').replace(/-/g, '').toLowerCase();
|
|
2364
|
+
if (!/^[0-9a-f]{32}$/.test(hex)) continue;
|
|
2365
|
+
return { id: hex.slice(0, 16), ms: Date.parse(r.timestamp || '') || 0 };
|
|
2366
|
+
}
|
|
2367
|
+
return { id: '', ms: 0 };
|
|
2368
|
+
}
|
|
1846
2369
|
function countNl(buf) {
|
|
1847
2370
|
let n = 0;
|
|
1848
2371
|
for (let i = 0; i < buf.length; i++) if (buf[i] === 0x0a) n++;
|
|
@@ -1886,82 +2409,477 @@ function myCitation() {
|
|
|
1886
2409
|
return { session_uuid: uuid, head_id: head.id };
|
|
1887
2410
|
}
|
|
1888
2411
|
|
|
2412
|
+
/* ── 폴더 identity (DESIGN-genetics-ssot.md §3) ──────────────────────────────
|
|
2413
|
+
*
|
|
2414
|
+
* 폴더의 소속은 **서버가 확정한다**(오너 결정 D1, 2026-09-14). 그래서 전에 여기 있던 로컬 입양
|
|
2415
|
+
* (「같은 폴더의 앞 세션과 같은 소속처」 — 2026-08-24 결정)은 대체되어 걷었다(§5.7).
|
|
2416
|
+
* 정본은 `www/portal/lib/folder_identity.php` 이고, 여기 사본은 **읽는 자가 있는 것**뿐이다 —
|
|
2417
|
+
* resolve 에 실을 git identity, push 제안에 실을 path identity, 보내기 전에 거르는 unsafe root.
|
|
2418
|
+
*
|
|
2419
|
+
* ★ 원격 주소는 **보내지 않는다** — 주소에 자격(`https://x-token:…@host/…`)이 들어 있을 수 있다.
|
|
2420
|
+
* 정규화한 identity 만 나간다. 경로는 언제나 마스킹본(`relHome`, realpath 뒤)이다.
|
|
2421
|
+
*/
|
|
2422
|
+
const FOLDER_GIT_SCHEMES = ['http', 'https', 'ssh', 'git', 'git+ssh'];
|
|
2423
|
+
const FOLDER_GIT_SHAPE = /^[a-z0-9][a-z0-9.-]{0,62}(\/[a-z0-9._~+%-]{1,64}){2,8}$/;
|
|
2424
|
+
const FOLDER_PATH_SHAPE = /^u[1-9][0-9]{0,9}@([0-9a-f]{16}|\*):~?\/[^\x00]{1,150}$/;
|
|
2425
|
+
|
|
2426
|
+
/** 원격 주소 → identity. 빈 문자열은 「git identity 아님」(path 로 간다). §3.1 */
|
|
2427
|
+
function folderGitIdentity(remote) {
|
|
2428
|
+
const s = String(remote ?? '').trim().replace(/\/+$/, '').replace(/\.git$/i, '');
|
|
2429
|
+
let host = '';
|
|
2430
|
+
let p = '';
|
|
2431
|
+
const url = s.match(/^([A-Za-z][A-Za-z0-9+.-]*):\/\/([^/]*)(.*)$/);
|
|
2432
|
+
if (url) {
|
|
2433
|
+
if (!FOLDER_GIT_SCHEMES.includes(url[1].toLowerCase())) return ''; // file:·기타는 identity 없음
|
|
2434
|
+
let auth = url[2];
|
|
2435
|
+
const at = auth.lastIndexOf('@');
|
|
2436
|
+
if (at >= 0) auth = auth.slice(at + 1); // ★ userinfo 에 자격이 들어 있다
|
|
2437
|
+
const colon = auth.indexOf(':');
|
|
2438
|
+
host = colon < 0 ? auth : auth.slice(0, colon); // 포트 버림 — 첫 ':' 에서 자른다(folder_git_identity 와 같은 자리)
|
|
2439
|
+
p = url[3];
|
|
2440
|
+
} else {
|
|
2441
|
+
const scp = s.match(/^([^/@]+@)?([^/:]+):(?!\/)(.+)$/);
|
|
2442
|
+
if (!scp) return ''; // 로컬 경로 remote
|
|
2443
|
+
host = scp[2];
|
|
2444
|
+
p = scp[3];
|
|
2445
|
+
}
|
|
2446
|
+
const seg = p.split('/').filter(Boolean);
|
|
2447
|
+
if (host === '' || seg.length < 2) return '';
|
|
2448
|
+
const id = `${host.toLowerCase()}/${seg.join('/').toLowerCase()}`;
|
|
2449
|
+
return FOLDER_GIT_SHAPE.test(id) && Buffer.byteLength(id, 'utf8') <= 191 ? id : '';
|
|
2450
|
+
}
|
|
2451
|
+
|
|
2452
|
+
/** (user_id, device, 마스킹 경로) → path identity. 모양이 틀리거나 191바이트를 넘으면 ''. §3.2 */
|
|
2453
|
+
function folderPathIdentity(userId, device, maskedPath) {
|
|
2454
|
+
let p = String(maskedPath ?? '').trim();
|
|
2455
|
+
if (p.length > 1) p = p.replace(/\/+$/, '') || '/'; // folder_path_trim 과 같은 규칙
|
|
2456
|
+
const id = `u${userId}@${device}:${p}`;
|
|
2457
|
+
return FOLDER_PATH_SHAPE.test(id) && Buffer.byteLength(id, 'utf8') <= 191 ? id : '';
|
|
2458
|
+
}
|
|
2459
|
+
|
|
2460
|
+
/** §3.4 — 세그먼트 접두 일치. 바인딩 `''` 은 저장소 전부를 덮는다(`apps/webx` 는 `apps/web` 아래가 아니다). */
|
|
2461
|
+
function folderSubpathCovers(binding, ask) {
|
|
2462
|
+
const b = String(binding ?? '');
|
|
2463
|
+
const a = String(ask ?? '');
|
|
2464
|
+
return b === '' || a === b || a.startsWith(b + '/');
|
|
2465
|
+
}
|
|
2466
|
+
|
|
2467
|
+
/** 흐릿한 자리 — 정본은 agent_session.php 의 AGENT_SESSION_PLACE_VAGUE(_UNDER). JS 는 벡터 파일로 같은 표를 잰다(§3.3). */
|
|
2468
|
+
const FOLDER_VAGUE = ['', '~', '~/', '/', '/tmp', '/private/tmp', '/var/tmp', '/var/folders', '/private/var'];
|
|
2469
|
+
const FOLDER_VAGUE_UNDER = ['/tmp/', '/private/tmp/', '/var/tmp/', '/var/folders/', '/private/var/'];
|
|
2470
|
+
|
|
2471
|
+
/**
|
|
2472
|
+
* 참이면 소속 저장·바인딩 제안·resolve 를 전부 막는다. **realpath 뒤에** 판정한다 — macOS 의
|
|
2473
|
+
* `/tmp` 는 `/private/tmp` 의 링크라 문자열로는 스크래치패드를 못 잡는다. 절대 `$HOME` 자신도 unsafe,
|
|
2474
|
+
* 그 **아래**는 아니다. §3.3
|
|
2475
|
+
*/
|
|
2476
|
+
function folderUnsafeRoot(p, home) {
|
|
2477
|
+
const real = (x) => { try { return fs.realpathSync(x); } catch { return x; } };
|
|
2478
|
+
let h = String(home || '').replace(/\/+$/, '');
|
|
2479
|
+
if (h) h = real(h);
|
|
2480
|
+
let s = String(p ?? '').trim();
|
|
2481
|
+
if ((s === '~' || s.startsWith('~/')) && h) s = h + s.slice(1);
|
|
2482
|
+
if (s.length > 1) s = s.replace(/\/+$/, '');
|
|
2483
|
+
if (FOLDER_VAGUE.includes(s)) return true;
|
|
2484
|
+
const tmp = process.env.TMPDIR ? real(String(process.env.TMPDIR).replace(/\/+$/, '')) : '';
|
|
2485
|
+
for (const x of new Set([s, real(s)])) {
|
|
2486
|
+
if (FOLDER_VAGUE.includes(x) || FOLDER_VAGUE_UNDER.some((pre) => x.startsWith(pre))) return true;
|
|
2487
|
+
if (['/Users', '/home', '/root', '/Volumes'].includes(x)) return true; // 집의 부모 · 마운트 뿌리
|
|
2488
|
+
if (tmp && tmp !== '/' && (x === tmp || x.startsWith(tmp + '/'))) return true;
|
|
2489
|
+
if (h && x === h) return true; // 절대 집 한 줄
|
|
2490
|
+
if (h && x.startsWith(h + '/')) {
|
|
2491
|
+
const rel = x.slice(h.length);
|
|
2492
|
+
if (['/.claude', '/.config', '/.codex', '/.cursor', '/Library'].some((d) => rel === d || rel.startsWith(d + '/'))) return true;
|
|
2493
|
+
if (['/Desktop', '/Downloads', '/Documents'].includes(rel)) return true; // 모아 두는 자리(그 아래는 안전)
|
|
2494
|
+
}
|
|
2495
|
+
}
|
|
2496
|
+
return false;
|
|
2497
|
+
}
|
|
2498
|
+
|
|
1889
2499
|
/**
|
|
1890
|
-
*
|
|
2500
|
+
* 서버로 나가는 경로 모양 — `relHome`(§3 표의 JS home_mask) 위에 서버의 원시 경로 술어
|
|
2501
|
+
* (`agent_session_mask_home_paths` — 이 파일의 `maskHomeInPick` 이 같은 세 규칙)를 한 번 더 건다.
|
|
2502
|
+
* 서버는 `/Users/<누구든>`·`/home/<누구든>`·`/root/…` 가 남은 path·git.root 를 **요청 통째로**
|
|
2503
|
+
* 400 raw_path 로 막는다(folder_path_raw) — 집 기준으로만 접으면 root 계정·남의 집 아래 폴더에서
|
|
2504
|
+
* resolve 가 영영 400 이다.
|
|
2505
|
+
*/
|
|
2506
|
+
function folderMask(p) { return maskHomeInPick(relHome(String(p ?? ''))); }
|
|
2507
|
+
|
|
2508
|
+
/** 한 자리(절대 경로)의 폴더 사실. `real`·`git.rootReal` 은 **이 기계 밖으로 안 나간다.** */
|
|
2509
|
+
function folderOf(dir) {
|
|
2510
|
+
let real = String(dir || '');
|
|
2511
|
+
try { real = fs.realpathSync(real); } catch { /* 없는 폴더면 준 값 그대로 */ }
|
|
2512
|
+
const out = { real, path: folderMask(real), unsafe: folderUnsafeRoot(real, os.homedir()), git: null };
|
|
2513
|
+
if (out.unsafe) return out;
|
|
2514
|
+
try {
|
|
2515
|
+
const top = spawnSync('git', ['-C', real, 'rev-parse', '--show-toplevel'], { encoding: 'utf8', timeout: 3000 });
|
|
2516
|
+
const t = top.status === 0 ? String(top.stdout || '').trim() : '';
|
|
2517
|
+
if (t) {
|
|
2518
|
+
let root = t;
|
|
2519
|
+
try { root = fs.realpathSync(root); } catch { /* */ }
|
|
2520
|
+
// **origin 하나만** 본다 — 원격이 여럿일 때 고르면 추측이 된다. 없으면 path 로 간다(§3.1).
|
|
2521
|
+
const o = spawnSync('git', ['-C', root, 'config', '--get', 'remote.origin.url'], { encoding: 'utf8', timeout: 3000 });
|
|
2522
|
+
const identity = o.status === 0 ? folderGitIdentity(o.stdout) : '';
|
|
2523
|
+
const subpath = real === root ? '' : (real.startsWith(root + '/') ? real.slice(root.length + 1) : null);
|
|
2524
|
+
if (identity && subpath !== null) out.git = { identity, subpath, root: folderMask(root), rootReal: root };
|
|
2525
|
+
}
|
|
2526
|
+
} catch { /* git 이 없는 기계 — path 로 간다 */ }
|
|
2527
|
+
return out;
|
|
2528
|
+
}
|
|
2529
|
+
|
|
2530
|
+
/** push 에 실을 폴더(§4.2). 못 만들면 null — path identity 는 사람 자격의 user id 가 있어야 선다. */
|
|
2531
|
+
function folderForPush(fo) {
|
|
2532
|
+
if (!fo || fo.unsafe || !fo.path) return null;
|
|
2533
|
+
if (fo.git) return { kind: 'git', identity: fo.git.identity, subpath: fo.git.subpath, path: fo.path };
|
|
2534
|
+
const uid = Number(credRead()?.user?.id) || 0;
|
|
2535
|
+
const identity = uid > 0 ? folderPathIdentity(uid, deviceId(), fo.path) : '';
|
|
2536
|
+
return identity ? { kind: 'path', identity, subpath: '', path: fo.path } : null;
|
|
2537
|
+
}
|
|
2538
|
+
|
|
2539
|
+
/*
|
|
2540
|
+
* `.genetics-private` 찾기(§5.5) — 자리에서 한 단계씩 올라가며 본다(파일이든 디렉터리든). git 뿌리
|
|
2541
|
+
* (path 면 그 폴더)에서 멈추고 `$HOME`·뿌리는 넘지 않는다. 결과는 경로별 60초 기억.
|
|
2542
|
+
* ⚠ 이것은 **이 클라이언트의 약속**이지 서버 스위치가 아니다(§5.4 무게 차이).
|
|
2543
|
+
*/
|
|
2544
|
+
const PRIVATE_TTL_MS = 60_000;
|
|
2545
|
+
const privateSeen = new Map(); // `${real}\0${stop}` → { at, hit }
|
|
2546
|
+
function privateFileFor(real, stopReal, home = os.homedir()) {
|
|
2547
|
+
const stop = (stopReal && (real === stopReal || String(real).startsWith(stopReal + '/'))) ? stopReal : real;
|
|
2548
|
+
const k = `${real}\0${stop}`;
|
|
2549
|
+
const c = privateSeen.get(k);
|
|
2550
|
+
if (c && Date.now() - c.at < PRIVATE_TTL_MS) return c.hit;
|
|
2551
|
+
let h = home;
|
|
2552
|
+
try { h = fs.realpathSync(home); } catch { /* */ }
|
|
2553
|
+
let hit = false;
|
|
2554
|
+
let d = real;
|
|
2555
|
+
for (let i = 0; i < 64 && d && d !== '/' && d !== h; i++) {
|
|
2556
|
+
if (fs.existsSync(path.join(d, '.genetics-private'))) { hit = true; break; }
|
|
2557
|
+
if (d === stop) break;
|
|
2558
|
+
const up = path.dirname(d);
|
|
2559
|
+
if (up === d) break;
|
|
2560
|
+
d = up;
|
|
2561
|
+
}
|
|
2562
|
+
privateSeen.set(k, { at: Date.now(), hit });
|
|
2563
|
+
return hit;
|
|
2564
|
+
}
|
|
2565
|
+
|
|
2566
|
+
/* ── resolve 캐시 `~/.config/genetics/resolve.json` (DESIGN-genetics-ssot.md §5.3) ──────────
|
|
1891
2567
|
*
|
|
1892
|
-
*
|
|
1893
|
-
*
|
|
1894
|
-
*
|
|
1895
|
-
* 그래서 **새로 시작한 세션은 색인 밖**이고, 한 번은 손으로 올려야 등록된다 —
|
|
1896
|
-
* 잊으면 그날 것이 통째로 안 간다(실측: 하루가 비었던 적이 있다, `8e14066`).
|
|
1897
|
-
*
|
|
1898
|
-
* 기계가 소속처를 못 고르는 것이 걸림돌이었다(이 기계에 소속처가 5곳). 규칙을 정했다
|
|
1899
|
-
* (사용자 결정 2026-08-24): **같은 작업 폴더의 세션과 같은 소속처.** 표를 안 만들고,
|
|
1900
|
-
* 같은 폴더면 같은 일이라 잘못 갈 위험이 낮다.
|
|
1901
|
-
*
|
|
1902
|
-
* ⚠ **섞여 있으면 안 고른다.** 한 폴더에 두 소속처가 있으면 추측이 되고, 추측으로
|
|
1903
|
-
* 남의 고객사에 대화가 올라가면 되돌릴 수 없다. 실측 2026-08-24: 폴더 6곳 중 5곳이
|
|
1904
|
-
* 소속처 하나로 깨끗했고, 섞인 1곳은 **전사 사본이 없는 것들**이라 폴더가 없었다.
|
|
1905
|
-
*
|
|
1906
|
-
* @param string $myDir 지금 세션 전사가 사는 폴더
|
|
1907
|
-
* @param array rows `[{dir, tenant, tenant_id}]` — 색인된 세션들
|
|
1908
|
-
* @return null|{tenant,tenant_id}|{ambiguous:number}
|
|
2568
|
+
* **폴더 층의 답은 이 파일 하나다.** 쓰는 자는 MCP 뿐이고(잠금 `resolve.json.lock`, §5.2 알고리즘),
|
|
2569
|
+
* 세포는 읽기만 한다(`by_path` 의 절대 realpath 로 cwd·repo_root 를 바로 찾는다).
|
|
2570
|
+
* 만료된 줄은 지우지 않고 `stale:true` 로 둔다 — 자동 올리기만 멈춘다. 시각은 epoch 밀리초.
|
|
1909
2571
|
*/
|
|
1910
|
-
|
|
1911
|
-
|
|
1912
|
-
|
|
1913
|
-
|
|
1914
|
-
|
|
1915
|
-
|
|
1916
|
-
|
|
1917
|
-
}
|
|
1918
|
-
|
|
1919
|
-
|
|
1920
|
-
/**
|
|
1921
|
-
function
|
|
1922
|
-
const
|
|
1923
|
-
|
|
1924
|
-
|
|
1925
|
-
|
|
1926
|
-
|
|
1927
|
-
|
|
1928
|
-
|
|
1929
|
-
|
|
1930
|
-
|
|
1931
|
-
|
|
1932
|
-
|
|
1933
|
-
|
|
1934
|
-
|
|
1935
|
-
|
|
1936
|
-
|
|
1937
|
-
|
|
2572
|
+
const RESOLVE_FILE = path.join(path.dirname(CRED_FILE), 'resolve.json');
|
|
2573
|
+
const RESOLVE_TTL_MS = 10 * 60_000; // 【선언】 §5.3
|
|
2574
|
+
const RESOLVE_MAX_FOLDERS = 256;
|
|
2575
|
+
const RESOLVE_MAX_ITEMS = 32; // §4.1 — 33건이면 서버가 400
|
|
2576
|
+
const resolveMine = new Map(); // key → { key, real, entry } — 이 프로세스가 적은 줄(자가 복구)
|
|
2577
|
+
|
|
2578
|
+
function resolveKeyOf(kind, identity, subpath, device, maskedPath) {
|
|
2579
|
+
return kind === 'git' ? `git:${identity}#${subpath || ''}` : `path:${device}:${maskedPath}`;
|
|
2580
|
+
}
|
|
2581
|
+
|
|
2582
|
+
/** 캐시에 줄을 적는다. rows: `[{ key, real, entry }]` — 빈 배열이면 만료 표시(stale)만 한다. */
|
|
2583
|
+
function resolveCacheWrite(rows) {
|
|
2584
|
+
for (const r of rows) resolveMine.set(r.key, r);
|
|
2585
|
+
return jsonFileUpdate(RESOLVE_FILE, (cur) => {
|
|
2586
|
+
const before = JSON.stringify([cur.folders || {}, cur.by_path || {}]);
|
|
2587
|
+
const folders = cur.folders && typeof cur.folders === 'object' ? { ...cur.folders } : {};
|
|
2588
|
+
const byPath = cur.by_path && typeof cur.by_path === 'object' ? { ...cur.by_path } : {};
|
|
2589
|
+
// 파일이 없거나 깨져 새로 시작하면 내 줄을 메모리 사본에서 되세운다.
|
|
2590
|
+
for (const r of (cur.folders ? rows : [...resolveMine.values()])) {
|
|
2591
|
+
folders[r.key] = r.entry;
|
|
2592
|
+
if (r.real) byPath[r.real] = r.key;
|
|
2593
|
+
}
|
|
2594
|
+
const now = Date.now();
|
|
2595
|
+
for (const [k, f] of Object.entries(folders)) {
|
|
2596
|
+
if (f && typeof f === 'object' && Number(f.expires_at) <= now && f.stale !== true) folders[k] = { ...f, stale: true };
|
|
2597
|
+
}
|
|
2598
|
+
const keys = Object.keys(folders);
|
|
2599
|
+
if (keys.length > RESOLVE_MAX_FOLDERS) {
|
|
2600
|
+
keys.sort((a, b) => (Number(folders[a]?.resolved_at) || 0) - (Number(folders[b]?.resolved_at) || 0));
|
|
2601
|
+
for (const k of keys.slice(0, keys.length - RESOLVE_MAX_FOLDERS)) delete folders[k];
|
|
2602
|
+
}
|
|
2603
|
+
for (const [p, k] of Object.entries(byPath)) if (!folders[k]) delete byPath[p];
|
|
2604
|
+
if (JSON.stringify([folders, byPath]) === before) return null; // 값이 같으면 안 쓴다
|
|
2605
|
+
return { v: 1, written_at: now, writer: `genetics-mcp/${VERSION}#${process.pid}`, folders, by_path: byPath };
|
|
2606
|
+
});
|
|
2607
|
+
}
|
|
2608
|
+
|
|
2609
|
+
/** 요청 몸통(§4.1) — 정규화 identity 와 마스킹 경로만. asks: `[{ key, uuid, fo, private_file }]` */
|
|
2610
|
+
function resolveRequestBody(asks, device) {
|
|
2611
|
+
return {
|
|
2612
|
+
v: 1, device,
|
|
2613
|
+
items: asks.slice(0, RESOLVE_MAX_ITEMS).map((a) => {
|
|
2614
|
+
const it = { key: a.key };
|
|
2615
|
+
if (a.uuid) it.uuid = a.uuid;
|
|
2616
|
+
it.path = a.fo.path;
|
|
2617
|
+
it.private_file = !!a.private_file;
|
|
2618
|
+
if (a.fo.git) it.git = { identity: a.fo.git.identity, subpath: a.fo.git.subpath, root: a.fo.git.root };
|
|
2619
|
+
return it;
|
|
2620
|
+
}),
|
|
2621
|
+
};
|
|
2622
|
+
}
|
|
2623
|
+
|
|
2624
|
+
const resolveView = new Map(); // 이 창의 자리(real) → 마지막 답 — 알림 문구(§5.4)가 읽는다
|
|
2625
|
+
const resolveUuidAsked = new Set();
|
|
2626
|
+
const resolveMovedAsked = new Map(); // uuid → 물은 시각
|
|
2627
|
+
const resolveMovedSaid = new Map(); // uuid → { from, to } — 색인을 실제로 고친 옮김
|
|
2628
|
+
let resolveFail = { n: 0, next_at: 0 }; // 실패 백오프 — 망 사정은 1분부터 두 배, 캐시 수명에서 멈춘다
|
|
2629
|
+
let resolveGlobalOn = null; // 서버의 전역 스위치(§4.1 auto_upload_globally_on) — 아직 못 들었으면 null
|
|
2630
|
+
let resolveCwdAsked = '';
|
|
2631
|
+
let resolveBootDone = false;
|
|
2632
|
+
let resolveBusy = false;
|
|
2633
|
+
|
|
2634
|
+
async function resolveFetch(cred, body) {
|
|
2635
|
+
const ac = new AbortController();
|
|
2636
|
+
const t = setTimeout(() => ac.abort(), 5000);
|
|
2637
|
+
try {
|
|
2638
|
+
const res = await fetch(`${BASE}/api/folders/resolve`, {
|
|
2639
|
+
method: 'POST',
|
|
2640
|
+
headers: { Authorization: `Bearer ${cred.token}`, 'Content-Type': 'application/json' },
|
|
2641
|
+
body: JSON.stringify(body),
|
|
2642
|
+
signal: ac.signal,
|
|
2643
|
+
});
|
|
2644
|
+
// §5.8 — 판은 모든 /api/* 머리에 온다. 부팅 직후의 판 알림이 도구 호출(api())을 기다리지 않게 여기서도 적는다.
|
|
2645
|
+
LAST_MIN_CLIENT = res.headers.get('x-genetics-min-client') || LAST_MIN_CLIENT;
|
|
2646
|
+
let data = null;
|
|
2647
|
+
try { data = JSON.parse(await res.text()); } catch { /* 몸통을 못 읽으면 null */ }
|
|
2648
|
+
return { status: res.status, data };
|
|
2649
|
+
} catch { return { status: 0, data: null }; } finally { clearTimeout(t); }
|
|
2650
|
+
}
|
|
2651
|
+
|
|
2652
|
+
/** 답이 말한 「그 세션이 서버 어디에 사는가」를 색인에 옮긴다 — 서버가 준 값만(§5.1). */
|
|
2653
|
+
function resolveSessionFacts(uuid, it, b) {
|
|
2654
|
+
const t = (x) => (x && Number(x.id) > 0
|
|
2655
|
+
? { tenant_id: Number(x.id), tenant_slug: String(x.slug || ''), tenant: String(x.name || '') } : null);
|
|
2656
|
+
if (it.state === 'bound' && it.session?.state === 'same' && t(b?.tenant)) {
|
|
2657
|
+
indexTenant(uuid, { ...t(b.tenant), src: 'resolve' });
|
|
2658
|
+
} else if (it.state === 'moved' && t(it.moved_to)) {
|
|
2659
|
+
const was = indexRead()[uuid];
|
|
2660
|
+
if (Number(was?.tenant_id) !== Number(it.moved_to.id) && indexTenant(uuid, { ...t(it.moved_to), src: 'moved' })) {
|
|
2661
|
+
resolveMovedSaid.set(uuid, { from: String(was?.tenant || ''), to: String(it.moved_to.name || '') });
|
|
2662
|
+
}
|
|
2663
|
+
} else if (it.state === 'conflict' && t(it.session?.tenant)) {
|
|
2664
|
+
indexTenant(uuid, { ...t(it.session.tenant), src: 'resolve' });
|
|
1938
2665
|
}
|
|
1939
|
-
|
|
1940
|
-
|
|
1941
|
-
|
|
1942
|
-
|
|
1943
|
-
|
|
1944
|
-
|
|
2666
|
+
// 개인정보 N10 · 부록 D-1·D-30 — 그 소속처가 이 uuid 를 「막는다 / 이제 안 막는다」고 답하면 막음 표에 옮긴다.
|
|
2667
|
+
if (it.state === 'bound' && t(b?.tenant)) autoBlockFromResolve(uuid, it.session, Number(b.tenant.id));
|
|
2668
|
+
}
|
|
2669
|
+
|
|
2670
|
+
/**
|
|
2671
|
+
* resolve 의 그 uuid 상태(`items[].session`, §4.1)가 이 기계에 무엇을 말하나 — **순수 함수**(개인정보 N10 · 부록 D-1·D-30).
|
|
2672
|
+
*
|
|
2673
|
+
* 서버의 모양(`folder_resolve_session_state()`): 이 소속처에서 가려졌거나·걷혔거나·올라가기 전 보류면
|
|
2674
|
+
* `{state:"hidden", why:"hidden"|"purged"|"tomb"}`(관문 7a — 409 `session_hidden`), 살아 있으면 `{state:"same", hold:bool}`
|
|
2675
|
+
* (보류면 관문 7b — 409 `session_hold`). `hold` 칸은 새 서버에서 **언제나** 실린다.
|
|
2676
|
+
*
|
|
2677
|
+
* ★ **푸는 것은 서버가 「지금은 안 막는다」를 증명했을 때만이다.** `same` 에 `hold` 칸이 없는 답은 옛 서버의 것이다 —
|
|
2678
|
+
* 옛 서버는 가려진 uuid 에도 `same`·`none` 을 답하므로, 그 답으로 표를 풀면 사람이 뺀 대화가 날마다 다시 간다
|
|
2679
|
+
* (개인정보 NEW-4 의 되돌림). `none` 도 같은 까닭으로 아무것도 안 푼다(옛 서버의 가림과 새 서버의 「행 없음」이
|
|
2680
|
+
* 같은 글자다) — 그 자리는 여전히 사람이 한 번 올려서 푼다.
|
|
2681
|
+
* @return {{hidden:boolean, tomb:boolean, hold:boolean, block:string, unblock:string[]}}
|
|
2682
|
+
* hidden·tomb·hold 는 문구(§5.4)가 고를 갈래 · block 은 409 를 기다리지 않고 세울 표의 코드(없으면 '') ·
|
|
2683
|
+
* unblock 은 풀어도 되는 표의 코드들
|
|
2684
|
+
*/
|
|
2685
|
+
function resolveSessionSays(session) {
|
|
2686
|
+
const s = session && typeof session === 'object' ? session : {};
|
|
2687
|
+
const out = { hidden: false, tomb: false, hold: false, block: '', unblock: [] };
|
|
2688
|
+
if (s.state === 'hidden') {
|
|
2689
|
+
if (s.why === 'tomb') out.tomb = true;
|
|
2690
|
+
else out.hidden = true; // hidden·purged·모르는 why — 「가렸거나 걷었다」
|
|
2691
|
+
out.block = 'session_hidden';
|
|
2692
|
+
} else if (s.state === 'same' && s.hold === true) {
|
|
2693
|
+
out.hold = true;
|
|
2694
|
+
out.block = 'session_hold';
|
|
2695
|
+
out.unblock = ['session_hidden']; // 살아 있는 행이다 — 가림은 풀렸다(표는 한 칸이라 session_hold 로 갈아 끼운다)
|
|
2696
|
+
} else if (s.state === 'same' && s.hold === false) {
|
|
2697
|
+
out.unblock = ['session_hidden', 'session_hold'];
|
|
1945
2698
|
}
|
|
1946
|
-
|
|
1947
|
-
|
|
1948
|
-
|
|
1949
|
-
|
|
1950
|
-
|
|
1951
|
-
|
|
1952
|
-
|
|
1953
|
-
|
|
1954
|
-
|
|
1955
|
-
|
|
2699
|
+
return out;
|
|
2700
|
+
}
|
|
2701
|
+
|
|
2702
|
+
/**
|
|
2703
|
+
* resolve 가 말한 막음을 색인의 `auto.blocked` 에 옮긴다(개인정보 N10 · 부록 D-1·D-30). 무엇을 했는지 낸다 —
|
|
2704
|
+
* `'set:<code>'` · `'clear:<code>'` · `''`(할 일 없음·이미 그렇다).
|
|
2705
|
+
*
|
|
2706
|
+
* ★ **세우기**: 409 를 한 번 받고서야 세우던 표를 resolve 의 답으로 먼저 세운다 — 그 헛걸음 한 번이 검열된 전사
|
|
2707
|
+
* 전체를 뺀 사람의 소속처로 보내는 일이다. 같은 코드·같은 소속처의 표가 이미 있으면 안 쓴다(30분마다 `at` 만 흔들지 않는다).
|
|
2708
|
+
* ⚠ (판 8 — 적합성 F7) **다른 소속처(`tenant_id > 0`)가 세운 표는 안 덮는다** — 덮으면 그 뒤 이 소속처의 `hold:false` 가
|
|
2709
|
+
* 그 표를 풀어, 다른 소속처 사람이 뺀 대화가 그리로 다시 간다(호환 N9 가 push 에서 막은 그 병). 이 소속처의 막음은
|
|
2710
|
+
* `autoBlockAside`(이 프로세스 동안)에 두고 게이트가 소속처를 견줘 읽는다 — `'aside:<code>'` 를 낸다.
|
|
2711
|
+
* ★ **풀기**: 그 소속처가 세운 표이고(`autoBlockClearedBy()` — 호환 N9 와 같은 견줌) 서버가 그 코드를 풀어도 된다고
|
|
2712
|
+
* 증명했을 때만(`resolveSessionSays().unblock`). 포털에서 가림·보류를 푼 사실이 이 기계에 닿는 유일한 길이다(D-30).
|
|
2713
|
+
* 판정은 잠금 안에서 파일의 표로 한다.
|
|
2714
|
+
*/
|
|
2715
|
+
function autoBlockFromResolve(uuid, session, tenantId, now = Date.now()) {
|
|
2716
|
+
const u = String(uuid || '');
|
|
2717
|
+
const tid = Number(tenantId) || 0;
|
|
2718
|
+
const says = resolveSessionSays(session);
|
|
2719
|
+
if (!AUTO_UUID_RE.test(u) || !(tid > 0) || (!says.block && !says.unblock.length)) return '';
|
|
2720
|
+
let did = '';
|
|
2721
|
+
try {
|
|
2722
|
+
if (says.block) {
|
|
2723
|
+
// (판 8 — 적합성 F7) 다른 소속처의 표는 안 덮는다 — 그때는 `aside:<code>`(이 프로세스의 곁 표, `autoBlockPut()`).
|
|
2724
|
+
const v = autoBlockPut(u, says.block, tid, now, 'resolve');
|
|
2725
|
+
did = v === 'set' ? `set:${says.block}` : (v === 'foreign' ? `aside:${says.block}` : '');
|
|
2726
|
+
} else {
|
|
2727
|
+
indexWrite(u, null, { auto: { blocked: null } }, '', (row) => {
|
|
2728
|
+
const b = row?.auto?.blocked;
|
|
2729
|
+
const go = !!b && says.unblock.includes(String(b.code)) && autoBlockClearedBy(b, tid);
|
|
2730
|
+
if (go) did = `clear:${String(b.code)}`;
|
|
2731
|
+
return go;
|
|
2732
|
+
});
|
|
2733
|
+
// 곁 표도 그 소속처의 것만 걷는다(파일의 다른 소속처 표는 위 견줌이 그대로 둔다).
|
|
2734
|
+
const a = autoBlockAside.get(u.toLowerCase());
|
|
2735
|
+
if (a && Number(a.tenant_id) === tid && says.unblock.includes(String(a.code))) {
|
|
2736
|
+
autoBlockAside.delete(u.toLowerCase());
|
|
2737
|
+
if (!did) did = `clear-aside:${String(a.code)}`;
|
|
2738
|
+
}
|
|
2739
|
+
}
|
|
2740
|
+
} catch { return ''; }
|
|
2741
|
+
return did;
|
|
2742
|
+
}
|
|
2743
|
+
|
|
2744
|
+
/**
|
|
2745
|
+
* `POST /api/folders/resolve` 한 번 — 이 창의 자리(process.cwd()) 하나에 대해 이 세션과 방금 본
|
|
2746
|
+
* uuid 들을 묻고, 답을 캐시·색인·알림 상태에 옮긴다. **사람 토큰만**(키면 서버가 403). 실패는 조용하다.
|
|
2747
|
+
*/
|
|
2748
|
+
async function resolveRun(uuids = []) {
|
|
2749
|
+
const cred = userCred();
|
|
2750
|
+
if (resolveBusy || !cred || !BASE || !hostAllowed(BASE)) return;
|
|
2751
|
+
if (Date.now() < resolveFail.next_at) return;
|
|
2752
|
+
resolveBusy = true;
|
|
2753
|
+
try {
|
|
2754
|
+
const cwd = process.cwd();
|
|
2755
|
+
resolveCwdAsked = cwd;
|
|
2756
|
+
const fo = folderOf(cwd);
|
|
2757
|
+
if (fo.unsafe) { // 집·뿌리·임시 — 묻지도 않는다(§3.3)
|
|
2758
|
+
resolveView.set(fo.real, { at: Date.now(), state: 'unsafe', fo });
|
|
2759
|
+
return;
|
|
2760
|
+
}
|
|
2761
|
+
const device = deviceId();
|
|
2762
|
+
const priv = privateFileFor(fo.real, fo.git ? fo.git.rootReal : fo.real);
|
|
2763
|
+
const mine = mySessionId();
|
|
2764
|
+
const list = [...new Set([mine, ...uuids])].filter((u) => /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(u || ''))
|
|
2765
|
+
.slice(0, RESOLVE_MAX_ITEMS);
|
|
2766
|
+
const askKey = fo.git ? resolveKeyOf('git', fo.git.identity, fo.git.subpath) : resolveKeyOf('path', '', '', device, fo.path);
|
|
2767
|
+
const asks = (list.length ? list : ['']).map((u) => ({
|
|
2768
|
+
key: crypto.createHash('sha256').update(`${askKey}|${u}`).digest('hex').slice(0, 8),
|
|
2769
|
+
uuid: u, fo, private_file: priv,
|
|
2770
|
+
}));
|
|
2771
|
+
for (const u of list) resolveUuidAsked.add(u);
|
|
2772
|
+
const got = await resolveFetch(cred, resolveRequestBody(asks, device));
|
|
2773
|
+
const now = Date.now();
|
|
2774
|
+
if (got.status === 0 || got.status >= 500) {
|
|
2775
|
+
resolveFail = { n: resolveFail.n + 1, next_at: now + Math.min(RESOLVE_TTL_MS, 60_000 * 2 ** resolveFail.n) };
|
|
2776
|
+
resolveView.set(fo.real, { at: now, state: 'offline', fo });
|
|
2777
|
+
resolveCacheWrite([]); // 만료된 줄을 stale 로 — 자동 올리기가 멈춘다
|
|
2778
|
+
return;
|
|
2779
|
+
}
|
|
2780
|
+
if (got.status === 429) {
|
|
2781
|
+
resolveFail = { n: resolveFail.n, next_at: now + (Number(got.data?.retry_after) > 0 ? Number(got.data.retry_after) * 1000 : 60_000) };
|
|
2782
|
+
/*
|
|
2783
|
+
* ★ **옆 창이 받아 둔 답을 쓴다**(호환 M7). 속도 제한은 **사람 단위**라, 창 스물다섯 개를 한꺼번에 열면
|
|
2784
|
+
* 대부분이 429 를 받는다. 전에는 그 창들이 아무 답도 못 가진 채로(문구도 없이) 돌았다 — 그런데 답은
|
|
2785
|
+
* 이미 이 기계에 있다(resolve.json 은 이 기계의 공용 답이다). 만료 안 된 줄이 있으면 그것을 이 창의
|
|
2786
|
+
* 답으로 삼는다(그래야 §5.4 동의 문구도 나가고, 그 뒤에야 이 창의 대화가 올라간다).
|
|
2787
|
+
*/
|
|
2788
|
+
const cache = resolveCacheRead();
|
|
2789
|
+
const adopted = resolveCache(cache.by_path?.[fo.real], cache, now);
|
|
2790
|
+
resolveView.set(fo.real, adopted && !adopted.stale
|
|
2791
|
+
? { at: now, state: adopted.state, fo, item: { state: adopted.state }, entry: adopted, private_file: priv, adopted: true }
|
|
2792
|
+
: { at: now, state: 'resolve_busy', fo });
|
|
2793
|
+
return;
|
|
2794
|
+
}
|
|
2795
|
+
// 401(자격 만료)·403 not_human(키)은 「사람 자격 없음」이다. 403 role(세션을 못 다루는 계정)은
|
|
2796
|
+
// 그 문구가 거짓이 되므로 아래 「모르는 답」 갈래로 보내 조용히 캐시 수명마다 다시 본다.
|
|
2797
|
+
if (got.status === 401 || (got.status === 403 && got.data?.code !== 'role')) {
|
|
2798
|
+
resolveFail = { n: 0, next_at: now + RESOLVE_TTL_MS };
|
|
2799
|
+
resolveView.set(fo.real, { at: now, state: 'no_human', fo });
|
|
2800
|
+
return;
|
|
2801
|
+
}
|
|
2802
|
+
if (got.status !== 200 || got.data?.ok !== true || !Array.isArray(got.data.items)) {
|
|
2803
|
+
resolveFail = { n: 0, next_at: now + RESOLVE_TTL_MS }; // 이 창구를 모르는 옛 서버 — 캐시 수명마다 다시 본다
|
|
2804
|
+
// 404 는 「이 서버에 그 창구가 없다」다(호환 M9) — 조용히 꺼지면 사람은 세포의 훅 레인이 왜 안 뜨는지 모른다.
|
|
2805
|
+
if (got.status === 404) resolveView.set(fo.real, { at: now, state: 'no_resolve', fo });
|
|
2806
|
+
return;
|
|
2807
|
+
}
|
|
2808
|
+
resolveFail = { n: 0, next_at: 0 };
|
|
2809
|
+
if (typeof got.data.auto_upload_globally_on === 'boolean') resolveGlobalOn = got.data.auto_upload_globally_on;
|
|
2810
|
+
const byKey = new Map(asks.map((a) => [a.key, a]));
|
|
2811
|
+
const rows = [];
|
|
2812
|
+
let view = null;
|
|
2813
|
+
for (const it of got.data.items) {
|
|
2814
|
+
const a = byKey.get(it?.key);
|
|
2815
|
+
if (!a || typeof it.state !== 'string') continue;
|
|
2816
|
+
const f = it.folder && typeof it.folder === 'object' ? it.folder : null;
|
|
2817
|
+
const b = it.binding && typeof it.binding === 'object' ? it.binding : null;
|
|
2818
|
+
const kind = f?.kind === 'git' || f?.kind === 'path' ? f.kind : (fo.git ? 'git' : 'path');
|
|
2819
|
+
const identity = String(f?.identity || (kind === 'git' ? fo.git?.identity || '' : ''));
|
|
2820
|
+
const subpath = kind === 'git' ? String(f?.subpath ?? fo.git?.subpath ?? '') : '';
|
|
2821
|
+
/*
|
|
2822
|
+
* 캐시에는 **폴더 층의 답**만 적는다(§5.1 ★). conflict·moved 는 그 uuid 의 사정이고, 서버는 바인딩을
|
|
2823
|
+
* 하나로 고른 뒤에만 그 둘을 낸다 — 폴더 줄은 bound 다. 그대로 적으면 옮겨진 세션 하나가 그 폴더
|
|
2824
|
+
* 전체를 「확정 아님」으로 만들어 세포의 훅 레인이 꺼진다(세포는 state=="bound" 만 믿는다, §6).
|
|
2825
|
+
*/
|
|
2826
|
+
const folderState = (it.state === 'conflict' || it.state === 'moved') && b ? 'bound' : it.state;
|
|
2827
|
+
/*
|
|
2828
|
+
* git 답이 물은 저장소를 덮는지 본다(§3.4 folder_subpath_covers). 안 덮는 답을 by_path 에 이으면
|
|
2829
|
+
* 세포가 이 폴더를 그 바인딩으로 읽는다 — 그런 줄은 캐시에 안 적는다(path 답은 path 가 git 을 이긴 것).
|
|
2830
|
+
*/
|
|
2831
|
+
const covers = kind !== 'git'
|
|
2832
|
+
|| (!!fo.git && identity === fo.git.identity && folderSubpathCovers(subpath, fo.git.subpath));
|
|
2833
|
+
const entry = {
|
|
2834
|
+
kind, identity, subpath, state: folderState,
|
|
2835
|
+
binding_id: Number(b?.id) || 0, auto_upload: b?.auto_upload === true,
|
|
2836
|
+
tenant_id: Number(b?.tenant?.id) || 0, tenant_slug: String(b?.tenant?.slug || ''), tenant: String(b?.tenant?.name || ''),
|
|
2837
|
+
confirmed_by: String(b?.confirmed_by?.name || ''), confirmed_at: String(b?.confirmed_at || ''),
|
|
2838
|
+
// 동의 시각(§4.1) — 이보다 앞선 전사는 후보가 아니다(D3). 아직 이 칸을 안 주는 서버는 confirmed_at 으로 물러선다.
|
|
2839
|
+
auto_upload_at: b?.auto_upload_at === undefined || b?.auto_upload_at === null ? '' : b.auto_upload_at,
|
|
2840
|
+
resolved_at: now, expires_at: now + RESOLVE_TTL_MS, stale: false, reason: String(it.reason || ''),
|
|
2841
|
+
};
|
|
2842
|
+
if (covers) rows.push({ key: resolveKeyOf(kind, identity, subpath, device, fo.path), real: fo.real, entry });
|
|
2843
|
+
if (a.uuid) resolveSessionFacts(a.uuid, it, b);
|
|
2844
|
+
if (!a.uuid || a.uuid === mine) view = { at: now, state: it.state, fo, item: it, entry, private_file: priv };
|
|
1956
2845
|
}
|
|
1957
|
-
|
|
2846
|
+
if (rows.length) resolveCacheWrite(rows);
|
|
2847
|
+
if (view) resolveView.set(fo.real, view);
|
|
2848
|
+
} finally {
|
|
2849
|
+
resolveBusy = false;
|
|
1958
2850
|
}
|
|
1959
|
-
indexNote(mine, { tenant: pick.tenant, tenant_id: pick.tenant_id, head_id: '', head_ms: 0, ...(pick.tenant_slug ? { tenant_slug: pick.tenant_slug } : {}) });
|
|
1960
|
-
process.stderr.write('genetics: 이 세션을 «' + pick.tenant
|
|
1961
|
-
+ '» 로 등록했다 — 같은 폴더의 앞 세션과 같은 곳이다. 이제 자동으로 올라간다.\n');
|
|
1962
2851
|
}
|
|
1963
2852
|
|
|
1964
|
-
|
|
2853
|
+
/** 심박마다 — 부르는 때(§5.3): 부팅 직후 · 자리가 바뀔 때 · 색인에 없는 uuid 를 처음 볼 때 · 캐시 만료 뒤. */
|
|
2854
|
+
async function resolveTick() {
|
|
2855
|
+
if (!userCred()) return;
|
|
2856
|
+
const cwd = process.cwd();
|
|
2857
|
+
let real = cwd;
|
|
2858
|
+
try { real = fs.realpathSync(cwd); } catch { /* */ }
|
|
2859
|
+
const mine = mySessionId();
|
|
2860
|
+
const v = resolveView.get(real);
|
|
2861
|
+
const now = Date.now();
|
|
2862
|
+
const due = !resolveBootDone
|
|
2863
|
+
|| cwd !== resolveCwdAsked
|
|
2864
|
+
|| (!!mine && !resolveUuidAsked.has(mine) && !indexRead()[mine])
|
|
2865
|
+
|| !v || now - v.at >= RESOLVE_TTL_MS
|
|
2866
|
+
|| (v.state === 'offline' && now >= resolveFail.next_at);
|
|
2867
|
+
if (!due) return;
|
|
2868
|
+
resolveBootDone = true;
|
|
2869
|
+
await resolveRun([]);
|
|
2870
|
+
}
|
|
2871
|
+
|
|
2872
|
+
/** 심박 응답이 「옮겨졌다」(F_MOVED)고 짚은 세션 — resolve 로 새 자리를 받는다. 같은 uuid 는 캐시 수명에 한 번. */
|
|
2873
|
+
async function resolveMoved(reply) {
|
|
2874
|
+
const now = Date.now();
|
|
2875
|
+
const ids = (reply || []).filter((v) => v.flags & BEAT_MOVED).map((v) => v.uuid)
|
|
2876
|
+
.filter((u) => now - (resolveMovedAsked.get(u) || 0) >= RESOLVE_TTL_MS);
|
|
2877
|
+
if (!ids.length) return;
|
|
2878
|
+
for (const u of ids) resolveMovedAsked.set(u, now);
|
|
2879
|
+
await resolveRun(ids);
|
|
2880
|
+
}
|
|
2881
|
+
|
|
2882
|
+
function beatTargets(extra = []) {
|
|
1965
2883
|
const idx = indexRead();
|
|
1966
2884
|
const mine = mySessionId();
|
|
1967
2885
|
const now = Date.now();
|
|
@@ -1985,6 +2903,12 @@ function beatTargets() {
|
|
|
1985
2903
|
});
|
|
1986
2904
|
if (out.length >= BEAT.MAX) break;
|
|
1987
2905
|
}
|
|
2906
|
+
// §5.5 — 확정·자동 폴더의 후보 중 서버 소속을 이 기계가 아직 모르는 사본도 판정(UNKNOWN·AHEAD…)을 받는다.
|
|
2907
|
+
// 심박은 행을 안 만든다(§4.5 — 받는 것은 판정뿐). 창을 닫을 때(stopBeat)는 안 싣는다.
|
|
2908
|
+
for (const r of extra) {
|
|
2909
|
+
if (out.length >= BEAT.MAX) break;
|
|
2910
|
+
if (!out.some((o) => o.uuid === r.uuid)) out.push(r);
|
|
2911
|
+
}
|
|
1988
2912
|
return out;
|
|
1989
2913
|
}
|
|
1990
2914
|
|
|
@@ -2073,96 +2997,747 @@ async function beatOnce(recs, { closing = false } = {}) {
|
|
|
2073
2997
|
} finally { clearTimeout(t); }
|
|
2074
2998
|
}
|
|
2075
2999
|
|
|
2076
|
-
/** 같은 고장은 한 번만. stdout 은 프로토콜 전용이라 stderr 로만 쓴다. */
|
|
2077
|
-
function beatSay(key, msg) {
|
|
2078
|
-
if (beatSaid.has(key)) return;
|
|
2079
|
-
beatSaid.add(key);
|
|
2080
|
-
try { process.stderr.write(`genetics: ${msg}\n`); } catch { /* 그것마저 막히면 넘어간다 */ }
|
|
3000
|
+
/** 같은 고장은 한 번만. stdout 은 프로토콜 전용이라 stderr 로만 쓴다. */
|
|
3001
|
+
function beatSay(key, msg) {
|
|
3002
|
+
if (beatSaid.has(key)) return;
|
|
3003
|
+
beatSaid.add(key);
|
|
3004
|
+
try { process.stderr.write(`genetics: ${msg}\n`); } catch { /* 그것마저 막히면 넘어간다 */ }
|
|
3005
|
+
}
|
|
3006
|
+
|
|
3007
|
+
/** 다시 붙었으면 붙었다고 말한다 — 안 말하면 사람이 「아직도 안 되나」를 짐작해야 한다. */
|
|
3008
|
+
function beatWell() {
|
|
3009
|
+
if (!beatSaid.size) return;
|
|
3010
|
+
beatSaid.clear();
|
|
3011
|
+
try { process.stderr.write('genetics: 심박이 다시 붙었다.\n'); } catch { /* 넘어간다 */ }
|
|
3012
|
+
}
|
|
3013
|
+
|
|
3014
|
+
/* 자동 올리기 — **서버 사본이 낡았을 때만.** 사용자 결정(2026-08-20)이라 기본이 켜짐이고,
|
|
3015
|
+
* 끄는 길을 함께 둔다(`GENETICS_NO_AUTO_PUSH=1`). 이 값은 「내부 대화가 서버에 쌓인다」를
|
|
3016
|
+
* 뜻하므로, 끄는 길이 없으면 결정이 아니라 함정이 된다. */
|
|
3017
|
+
/**
|
|
3018
|
+
* 이 텍스트와 환경변수로 **자동 올리기가 켜졌는지**. 순수 함수 — 시험이 걷는다.
|
|
3019
|
+
*
|
|
3020
|
+
* ⚠ 위 `cfg` 와 **같은 우선순위**: 환경변수가 파일을 이긴다. 반대로 짰다가 실제 사고를
|
|
3021
|
+
* 냈다(2026-08-12) — 오버라이드가 안 먹는 설정은 안전한 기본값이 아니라 함정이다.
|
|
3022
|
+
*/
|
|
3023
|
+
function autoPushFromText(text, env) {
|
|
3024
|
+
const K = 'GENETICS_NO_AUTO_PUSH';
|
|
3025
|
+
if (env && Object.prototype.hasOwnProperty.call(env, K)) return !env[K];
|
|
3026
|
+
let v = '';
|
|
3027
|
+
for (const line of String(text || '').split('\n')) {
|
|
3028
|
+
const m = line.match(/^\s*([A-Z_]+)\s*=\s*(.*)\s*$/);
|
|
3029
|
+
if (m && m[1] === K) v = m[2];
|
|
3030
|
+
}
|
|
3031
|
+
return !v;
|
|
3032
|
+
}
|
|
3033
|
+
|
|
3034
|
+
/**
|
|
3035
|
+
* **스위치를 매번 다시 읽는다.** 예전에는 시작할 때 한 번만 읽어서, 켠 사람이 같은
|
|
3036
|
+
* 「꺼져 있습니다」를 다시 보고 「안 켜졌다」고 읽었다 — 2026-08-24 내가 그 함정에 빠졌다.
|
|
3037
|
+
*
|
|
3038
|
+
* 안내문으로 덮는 것보다 **켜면 켜지는 것**이 맞다. 파일은 200바이트 남짓이고 심박은
|
|
3039
|
+
* 20초에 한 번이라 비용이 없다. 자격은 그대로 시작할 때 값을 쓴다 — 여기서 다시 읽는 것은
|
|
3040
|
+
* **사람이 쥐는 스위치 하나**뿐이다.
|
|
3041
|
+
*/
|
|
3042
|
+
function autoPushOn() {
|
|
3043
|
+
let text = '';
|
|
3044
|
+
try { text = fs.readFileSync(ENV_FILE, 'utf8'); } catch { /* 없으면 env 로만 */ }
|
|
3045
|
+
return autoPushFromText(text, process.env);
|
|
3046
|
+
}
|
|
3047
|
+
|
|
3048
|
+
/*
|
|
3049
|
+
* ⚠⚠ **꺼져 있으면 말한다.** 전에는 `if (!AUTO_PUSH) return;` 한 줄로 조용히 빠졌다.
|
|
3050
|
+
*
|
|
3051
|
+
* 그 침묵의 값을 2026-08-23 에 쟀다: 이 기계의 세션이 **하루 넘게**(8-21 14:20, 6,094줄에서
|
|
3052
|
+
* 멈춤 — 로컬은 10,476줄) 서버에 안 올라갔고, 본문이 없으니 두뇌가 위키를 못 굽고,
|
|
3053
|
+
* 그래서 그날 배운 것이 팀 기억에 **한 장도** 안 남았다. 아무도 그 사실을 몰랐다 —
|
|
3054
|
+
* 파고들어야 보였다.
|
|
3055
|
+
*
|
|
3056
|
+
* 이 저장소는 세포를 필수로 만들며 규칙을 세웠다: **「없으면 막지 말고 알려주면서 유도한다」.**
|
|
3057
|
+
* 같은 규칙이 여기에도 걸린다. 끄는 것은 사람의 선택이지만, **끈 줄 모르는 것은 선택이 아니다.**
|
|
3058
|
+
*/
|
|
3059
|
+
let autoPushSaid = null; // 마지막으로 말한 상태 — 스위치가 바뀌면 다시 말한다
|
|
3060
|
+
function autoPushSilence() {
|
|
3061
|
+
if (autoPushSaid === false) return;
|
|
3062
|
+
autoPushSaid = false;
|
|
3063
|
+
/*
|
|
3064
|
+
* ⚠ **한 상태에 한 문구.** 전에는 여기 따로 쓴 문장과 §5.4 의 「bound·env off」가 같은 사실을 두 가지로
|
|
3065
|
+
* 말했다(SSOT 검토 m18) — 어느 쪽이 정본인지 알 수 없으면 다음 사람이 둘 다 고치거나 둘 다 안 고친다.
|
|
3066
|
+
* 정본은 §5.4 표뿐이다. stdout 은 프로토콜이니 사람이 보는 자리(stderr)로만 말한다.
|
|
3067
|
+
*/
|
|
3068
|
+
process.stderr.write(`${tenantNoticeText({ state: 'bound', auto_upload: true, env_off: true })}\n`);
|
|
3069
|
+
}
|
|
3070
|
+
const autoPushAt = new Map();
|
|
3071
|
+
/*
|
|
3072
|
+
* §5.4 「bound·auto on」(동의 표시)을 도구 꼬리로 낸 **폴더들** — 그 폴더의 전사는 그 뒤에만 올라간다.
|
|
3073
|
+
*
|
|
3074
|
+
* ⚠ **전에는 전역 불리언 하나였다**(개인정보 N8). 문구는 `process.cwd()` 의 폴더로 계산되는데 올릴 대상은
|
|
3075
|
+
* **전사가 있는 폴더**로 정해진다 — 확정 폴더 A 에서 열린 창이 확정 폴더 B 로 `cd` 한 뒤 도구를 부르면
|
|
3076
|
+
* B 의 동의문이 나가고 그 깃발 하나로 **A 의 대화**가 풀렸다. A 의 문구는 한 번도 안 나갔는데도.
|
|
3077
|
+
* 그래서 폴더(=바인딩) 열쇠별로 센다.
|
|
3078
|
+
* ⚠ **옆 창의 대화에도 건다**(개인정보 N9). 후보의 대부분은 이 창의 것이 아니라 같은 폴더의 **옆 창** 전사다.
|
|
3079
|
+
* 전에는 그것들이 아무 고지 없이 올라갔다 — 이 창이 아직 그 폴더의 문구를 한 번도 안 냈어도.
|
|
3080
|
+
* 이제는 **그 폴더의 동의문이 이 기계의 이 창에서 한 번 나간 뒤에만** 그 폴더의 전사를 올린다.
|
|
3081
|
+
* ★ 그래도 **옆 대화 안에서는 여전히 아무 말도 안 나간다** — 고지는 도구 응답 꼬리로만 갈 수 있고,
|
|
3082
|
+
* 도구를 한 번도 안 부른 대화에는 꼬리가 없기 때문이다. 이 자리가 §5.5 가 안고 있는 한계다.
|
|
3083
|
+
*/
|
|
3084
|
+
const autoConsentSaid = new Set();
|
|
3085
|
+
|
|
3086
|
+
/** 동의 문구가 가리킨 **폴더 하나**의 열쇠 — resolve 캐시의 열쇠와 같은 재료(kind·identity·subpath)다. 못 만들면 ''. */
|
|
3087
|
+
function autoConsentKey(e) {
|
|
3088
|
+
if (!e || typeof e !== 'object') return '';
|
|
3089
|
+
const id = String(e.identity || '');
|
|
3090
|
+
if (!id) return '';
|
|
3091
|
+
return e.kind === 'git' ? `git:${id}#${String(e.subpath || '')}` : `path:${id}`;
|
|
3092
|
+
}
|
|
3093
|
+
|
|
3094
|
+
/** 그 폴더의 동의문이 이 창에서 한 번이라도 나갔나(§5.5). 열쇠를 못 만들면 **안 나간 것으로 친다**. */
|
|
3095
|
+
function autoConsentGiven(e) {
|
|
3096
|
+
const k = autoConsentKey(e);
|
|
3097
|
+
return k !== '' && autoConsentSaid.has(k);
|
|
3098
|
+
}
|
|
3099
|
+
|
|
3100
|
+
/* ── 확정 폴더 자동 올리기 (DESIGN-genetics-ssot.md §5.5) ──────────────────────────────────
|
|
3101
|
+
*
|
|
3102
|
+
* 사람이 포털에서 폴더의 소속처를 **확정하고 자동 올리기를 켠** 곳의 대화는 검열 뒤 올라간다(오너 결정 D2).
|
|
3103
|
+
* 거르개의 폴더 입력은 **resolve 캐시 하나**다(§5.1 ★ — 색인에는 binding_id·auto_upload 를 두지 않는다).
|
|
3104
|
+
* 전사가 사는 자리(`transcriptDir(폴더)`)로 폴더 답을 잇고, 보내기 직전에 본문의 자리로 한 번 더 잰다(autoPrepare).
|
|
3105
|
+
*
|
|
3106
|
+
* 한 바퀴(심박 한 번)에 **한 건**만 올린다. 판정은 심박이 준다 — UNKNOWN 이면 첫 업로드, AHEAD 면 갱신,
|
|
3107
|
+
* SAME·BEHIND 면 안 올린다. 실패는 uuid 별로 물러선다(색인 `auto`).
|
|
3108
|
+
*
|
|
3109
|
+
* ⚠ 확정 바인딩이 **없는** 폴더의 앞선 사본(사람이 push 한 세션)은 옛 길 그대로 갱신한다(2026-08-20 결정 —
|
|
3110
|
+
* 「서버 사본이 낡았을 때만」). 확정 바인딩이 선 폴더의 행은 옛 길을 안 탄다 — 거르개 전부가 새 길에서 걸린다.
|
|
3111
|
+
*/
|
|
3112
|
+
const AUTO_SCAN_EVERY_MS = 5 * 60_000; // 되찾기 훑기는 5분에 한 번
|
|
3113
|
+
const AUTO_SCAN_MAX_AGE_MS = 30 * 24 * 3600_000; // 【선언】 mtime 30일 안
|
|
3114
|
+
const AUTO_SCAN_PER_FOLDER = 200; // 폴더당 최대 200개
|
|
3115
|
+
const AUTO_BODY_MAX = 32 * 1024 * 1024; // 【선언】 검열본 32MB — 넘으면 안 올리고 한 번 말한다
|
|
3116
|
+
const AUTO_BACKOFF_BASE_MS = 60_000; // 실패 백오프 1분 → 2분 → 4분 …
|
|
3117
|
+
const AUTO_BACKOFF_CAP_MS = 6 * 3600_000; // … 6시간 상한
|
|
3118
|
+
const AUTO_BACKOFF_LONG_MS = 24 * 3600_000; // 403·409·422·426 → 24시간
|
|
3119
|
+
const AUTO_UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
3120
|
+
/*
|
|
3121
|
+
* 【선언】 **사람의 손이 세운 409 는 백오프를 안 탄다**(개인정보 NEW-4 · §5.5 7 ★).
|
|
3122
|
+
* 이 둘은 「이 대화는 여기 올리지 마라」는 사람의 답이라 시간이 지난다고 바뀌지 않는다 —
|
|
3123
|
+
* 24시간마다 다시 보내면 검열된 전사 전체가 날마다 그 소속처 서버로 가서 파싱된다(서버가
|
|
3124
|
+
* 관문 앞에서 막아 디스크에는 안 남더라도 그렇다). `too_old` 와 같은 대우를 한다.
|
|
3125
|
+
*/
|
|
3126
|
+
const AUTO_STICKY_409 = new Set(['session_hidden', 'session_hold']);
|
|
3127
|
+
/*
|
|
3128
|
+
* 【선언】 **성공한 올리기 하나가 서버발 막음 표를 전부 지운다**(호환 C2 · 개인정보 NEW-4).
|
|
3129
|
+
* 사람이 손으로 올린 `push_session` 도 이 길로 온다 — 전에는 거절문이 시키는 대로 손으로 올려도
|
|
3130
|
+
* `auto.too_old` 가 그대로 남아 그 uuid 가 그 기계에서 영영 자동으로 안 올라갔다.
|
|
3131
|
+
* 서버가 그 본문을 받았다는 것은 그 uuid 에 대한 막음이 지금은 안 선다는 말이다.
|
|
3132
|
+
*
|
|
3133
|
+
* ⚠⚠ **(호환 N9) 단, `blocked` 는 받은 소속처가 그 표를 세운 소속처일 때만 지운다.** 막음 표는 소속처에 붙은
|
|
3134
|
+
* 사람의 답이다(`blocked.tenant_id`). 전에는 이 상수에 `blocked: null` 이 들어 있어, 같은 대화를 사람이 **다른**
|
|
3135
|
+
* 소속처 B 로 `push_session` 하기만 해도 A 가 세운 「올리지 마라」가 지워지고 A 로의 자동 올리기가 다시 섰다 —
|
|
3136
|
+
* 다시 설 때마다 검열된 전사 전체가 A 로 한 번 더 간다. 그래서 무조건 지우는 칸(`too_old`)만 여기 두고,
|
|
3137
|
+
* `blocked` 는 `autoClearOnPush(받은 소속처)` 가 싣는 `unblock_tenant` 를 `indexWrite()` 가 **잠금 안에서**
|
|
3138
|
+
* 파일의 표와 견줘 가른다(`autoBlockClearedBy()`). 그 칸은 행에 안 남는다.
|
|
3139
|
+
*/
|
|
3140
|
+
const AUTO_CLEAR_ON_PUSH = { too_old: 0, too_old_mtime: 0 };
|
|
3141
|
+
const AUTO_UNBLOCK_KEY = 'unblock_tenant';
|
|
3142
|
+
|
|
3143
|
+
/**
|
|
3144
|
+
* 【선언】 호환 N9 — 받은 소속처(`tenantId`)가 그 막음 표를 지워도 되나. **순수 함수.**
|
|
3145
|
+
* 표가 없으면 참(지울 것이 없다) · 표에 소속처가 없으면(옛 판이 적은 표) 참 · 같은 소속처면 참 · 다르면 거짓.
|
|
3146
|
+
*/
|
|
3147
|
+
function autoBlockClearedBy(blocked, tenantId) {
|
|
3148
|
+
if (!blocked || typeof blocked !== 'object') return true;
|
|
3149
|
+
const bt = Number(blocked.tenant_id) || 0;
|
|
3150
|
+
return bt === 0 || bt === (Number(tenantId) || 0);
|
|
3151
|
+
}
|
|
3152
|
+
|
|
3153
|
+
/** 성공한 올리기가 `auto` 에 합칠 칸 — `tenantId` 는 **서버가 받았다고 답한** 소속처다(호환 N9). */
|
|
3154
|
+
function autoClearOnPush(tenantId) {
|
|
3155
|
+
return { ...AUTO_CLEAR_ON_PUSH, [AUTO_UNBLOCK_KEY]: Number(tenantId) || 0 };
|
|
3156
|
+
}
|
|
3157
|
+
|
|
3158
|
+
/*
|
|
3159
|
+
* 【선언】 (판 8 — 적합성 F7) **막음 표 한 칸은 먼저 세운 소속처의 것이다 — 다른 소속처의 답은 그 표를 덮지 않는다.**
|
|
3160
|
+
* `auto.blocked` 는 한 칸이다. 전에는 resolve(`autoBlockFromResolve()`)와 409(`autoUploadOne()` 의 붙박이 갈래)가
|
|
3161
|
+
* 「같은 코드·같은 소속처」만 보고 덮어, 소속처 B 의 `hidden` 이 A 가 세운 `session_hidden` 을 갈아 끼웠다 —
|
|
3162
|
+
* 그 뒤 B 의 `same`·`hold:false` 가 `autoBlockClearedBy()` 로 그 표를 풀면 **A 가 뺀 대화가 A 로 다시 간다**.
|
|
3163
|
+
* 그래서 파일의 표가 다른 소속처(`tenant_id > 0`)의 것이면 안 쓰고, B 의 막음은 **이 프로세스의 곁 표**
|
|
3164
|
+
* (`autoBlockAside`, uuid → {code, tenant_id, at})에 둔다. 게이트(`autoSkipReason()`)는 `ctx.aside` 로 그 표를
|
|
3165
|
+
* **소속처를 견줘** 읽는다 — B 로는 안 보내고, A 의 표는 그대로다. 곁 표는 프로세스가 끝나면 사라지지만 한 바퀴는
|
|
3166
|
+
* 늘 resolve 가 먼저라(§5.5) 새 서버의 답이 올리기 전에 다시 세운다. 옛 서버(409 만 말한다)에서는 프로세스마다
|
|
3167
|
+
* 한 번 보낸 뒤 선다 — 두 소속처가 같은 uuid 를 함께 막은 드문 자리의 값이다. 푸는 길은 파일의 표와 같다
|
|
3168
|
+
* (그 소속처로의 성공한 올리기 — `indexWrite()` 의 `unblock_tenant` · 그 소속처 resolve 의 `hold:false`).
|
|
3169
|
+
*/
|
|
3170
|
+
const autoBlockAside = new Map();
|
|
3171
|
+
|
|
3172
|
+
/**
|
|
3173
|
+
* 막음 표를 세워도 되나 — **순수 함수**(판 8 — 적합성 F7). `'set'` 세운다 · `'same'` 같은 코드·같은 소속처의 표가 이미
|
|
3174
|
+
* 있다(안 쓴다) · `'foreign'` 다른 소속처(`tenant_id > 0`)의 표가 서 있다(안 덮는다). 소속처 없는 옛 표와 같은 소속처의
|
|
3175
|
+
* 다른 코드(가림 → 보류)는 `'set'` 이다.
|
|
3176
|
+
*/
|
|
3177
|
+
function autoBlockSetVerdict(blocked, code, tenantId) {
|
|
3178
|
+
if (!blocked || typeof blocked !== 'object') return 'set';
|
|
3179
|
+
const bt = Number(blocked.tenant_id) || 0;
|
|
3180
|
+
const tid = Number(tenantId) || 0;
|
|
3181
|
+
if (String(blocked.code) === String(code) && bt === tid) return 'same';
|
|
3182
|
+
if (bt > 0 && bt !== tid) return 'foreign';
|
|
3183
|
+
return 'set';
|
|
3184
|
+
}
|
|
3185
|
+
|
|
3186
|
+
/**
|
|
3187
|
+
* 막음 표를 잠금 안에서 세운다 — 다른 소속처의 표가 서 있으면 안 덮고 곁 표(`autoBlockAside`)에 둔다.
|
|
3188
|
+
* resolve 와 409 가 같은 이 자리를 쓴다. `autoBlockSetVerdict()` 의 답을 낸다(못 쓴 예외는 위로).
|
|
3189
|
+
*/
|
|
3190
|
+
function autoBlockPut(uuid, code, tenantId, now = Date.now(), src = '') {
|
|
3191
|
+
const u = String(uuid || '');
|
|
3192
|
+
const tid = Number(tenantId) || 0;
|
|
3193
|
+
let v = '';
|
|
3194
|
+
const blocked = { code, tenant_id: tid, at: now, ...(src ? { src } : {}) };
|
|
3195
|
+
indexWrite(u, null, { auto: { blocked } }, '', (row) => {
|
|
3196
|
+
v = autoBlockSetVerdict(row?.auto?.blocked, code, tid);
|
|
3197
|
+
return v === 'set';
|
|
3198
|
+
});
|
|
3199
|
+
if (v === 'foreign' && tid > 0) autoBlockAside.set(u.toLowerCase(), { code, tenant_id: tid, at: now });
|
|
3200
|
+
return v;
|
|
3201
|
+
}
|
|
3202
|
+
|
|
3203
|
+
/* ── 「이 대화는 올리지 마라」는 색인 밖에 산다 (호환 검토 H2) ─────────────────────────────
|
|
3204
|
+
*
|
|
3205
|
+
* `sessions.json` 은 옛 판(≤2.18)이 **잠금 없이 절단해서** 쓴다. 그래서 그 파일에만 둔 보류(`auto.hold`)는
|
|
3206
|
+
* 옛 판 창 하나가 지나가면 남의 행과 함께 사라지고, 서버에 아직 그 대화가 없으면 보류는 **어디에도 없다**
|
|
3207
|
+
* (첫 업로드 전의 hold 는 404 다 — §5.8). 보류만 따로, **옛 판이 이름조차 모르는 파일**에 둔다.
|
|
3208
|
+
*
|
|
3209
|
+
* ★ **못 읽으면 닫는 쪽으로 실패한다.** 이 파일이 있는데 못 읽히면 「보류가 없다」가 아니라
|
|
3210
|
+
* 「무엇이 보류인지 모른다」이므로 그 바퀴는 아무것도 안 올린다.
|
|
3211
|
+
*/
|
|
3212
|
+
const HOLDS_FILE = path.join(path.dirname(CRED_FILE), 'auto-holds.json');
|
|
3213
|
+
|
|
3214
|
+
/** @return {{ok: boolean, set: Set<string>}} ok=false 면 파일이 있는데 못 읽었다 — 전부 보류로 친다. */
|
|
3215
|
+
function autoHoldsRead() {
|
|
3216
|
+
const got = jsonReadRetry(HOLDS_FILE);
|
|
3217
|
+
if (got.raw === null && !got.err) return { ok: true, set: new Set() }; // 아직 아무도 안 보류했다
|
|
3218
|
+
if (!got.obj) return { ok: false, set: new Set() };
|
|
3219
|
+
const h = got.obj.holds && typeof got.obj.holds === 'object' ? got.obj.holds : {};
|
|
3220
|
+
const set = new Set();
|
|
3221
|
+
for (const [u, v] of Object.entries(h)) if (AUTO_UUID_RE.test(u) && v && v.hold !== false) set.add(u.toLowerCase());
|
|
3222
|
+
return { ok: true, set };
|
|
3223
|
+
}
|
|
3224
|
+
|
|
3225
|
+
/** 보류를 적는다(§5.8). `local` 은 「서버에는 아직 못 적었다」 — 알림이 그 사실을 말한다. */
|
|
3226
|
+
function autoHoldSet(uuid, local = true) {
|
|
3227
|
+
const u = String(uuid || '').toLowerCase();
|
|
3228
|
+
if (!AUTO_UUID_RE.test(u)) return false;
|
|
3229
|
+
const r = jsonFileUpdate(HOLDS_FILE, (cur) => {
|
|
3230
|
+
const holds = cur.holds && typeof cur.holds === 'object' ? { ...cur.holds } : {};
|
|
3231
|
+
holds[u] = { hold: true, at: Date.now(), local: !!local };
|
|
3232
|
+
return { v: 1, holds };
|
|
3233
|
+
});
|
|
3234
|
+
return r === 'written' || r === 'same';
|
|
3235
|
+
}
|
|
3236
|
+
|
|
3237
|
+
function resolveCacheRead() {
|
|
3238
|
+
try {
|
|
3239
|
+
const j = JSON.parse(fs.readFileSync(RESOLVE_FILE, 'utf8'));
|
|
3240
|
+
return j && typeof j === 'object' && !Array.isArray(j) ? j : {};
|
|
3241
|
+
} catch { return {}; }
|
|
3242
|
+
}
|
|
3243
|
+
|
|
3244
|
+
/** 폴더 열쇠 하나의 답(§5.3) — 없으면 null. 만료됐으면 쓰는 자가 아직 안 세웠어도 `stale` 로 읽는다(자동 올리기만 멈춘다). */
|
|
3245
|
+
function resolveCache(key, cache = resolveCacheRead(), now = Date.now()) {
|
|
3246
|
+
const f = cache?.folders?.[key];
|
|
3247
|
+
if (!f || typeof f !== 'object') return null;
|
|
3248
|
+
return { ...f, stale: f.stale === true || !(Number(f.expires_at) > now) };
|
|
3249
|
+
}
|
|
3250
|
+
|
|
3251
|
+
/**
|
|
3252
|
+
* 그 폴더의 **동의 시각**(epoch ms) — 이보다 앞선 대화는 후보가 아니다(오너 결정 D3, 개인정보 M5).
|
|
3253
|
+
*
|
|
3254
|
+
* ★ **되짚어 올리지 않는다.** 사람이 폴더를 확정하고 자동 올리기를 켠 것은 「앞으로」에 대한 동의이지
|
|
3255
|
+
* 그 폴더에서 지난 30일 동안 나눈 대화 전부에 대한 동의가 아니다.
|
|
3256
|
+
* 정본은 서버가 주는 바인딩의 `auto_upload_at` 이다. 그 칸이 없는 서버(지금 배포본)에서는 **확정 시각**
|
|
3257
|
+
* (`confirmed_at`)으로 물러선다 — 자동 올리기는 확정 뒤에만 켤 수 있으므로 확정 시각은 언제나
|
|
3258
|
+
* 동의 시각보다 **앞이거나 같다**. 즉 덜 거르는 쪽이 아니라 **더 거르지 못하는 쪽**의 안전한 하한이다.
|
|
3259
|
+
* 둘 다 없으면 0 — 거르지 않는다(그 자리는 §4.1 에 칸이 서야 닫힌다).
|
|
3260
|
+
*/
|
|
3261
|
+
function autoFloorMs(e) {
|
|
3262
|
+
const ms = (v) => {
|
|
3263
|
+
if (typeof v === 'number' && v > 0) return v > 1e12 ? v : v * 1000; // epoch 초·밀리초 둘 다 받는다
|
|
3264
|
+
const t = Date.parse(String(v || '').replace(' ', 'T'));
|
|
3265
|
+
return Number.isFinite(t) ? t : 0;
|
|
3266
|
+
};
|
|
3267
|
+
return ms(e?.auto_upload_at) || ms(e?.confirmed_at) || 0;
|
|
3268
|
+
}
|
|
3269
|
+
|
|
3270
|
+
/**
|
|
3271
|
+
* 그 후보가 **서버가 재는 나이**를 아직 못 받았나 — `head_ms` 는 마지막 발화 시각(`transcriptHead().ms`)이다.
|
|
3272
|
+
*
|
|
3273
|
+
* ★ **mtime 과 마지막 발화는 다른 값이다**(개인정보 N3). 클라이언트는 파일의 mtime 으로 거르고 서버 관문 11 은
|
|
3274
|
+
* **본문의 마지막 발화**로 잰다(`folder_auto_gate_age()`). 동의 앞에 끝났는데 그 뒤에 **손만 닿은** 전사
|
|
3275
|
+
* (체크아웃·백업 복원·편집기)는 mtime 이 새것이라 클라이언트를 지나고, 검열된 본문이 소속처의 사설 자리에
|
|
3276
|
+
* **한 번 쓰인 뒤** 409 로 지워진다 — 그리고 24시간마다 되풀이됐다. 그래서 판정은 머리로 한다.
|
|
3277
|
+
* mtime 은 싼 앞거르개로만 남는다.
|
|
3278
|
+
*
|
|
3279
|
+
* ⚠ **그 앞거르개는 불변식이 아니라 「거의 언제나 참인 어림」이다**(호환 C8). 전에 여기와 `autoSkipReason()` 이
|
|
3280
|
+
* 「mtime 이 하한보다 이르면 마지막 발화도 **반드시** 그보다 이르다」고 적었는데, 그 둘은 **다른 시계**가 쓴다 —
|
|
3281
|
+
* 발화 시각은 이 기계의 벽시계가, mtime 은 파일 계통이 찍는다. 시계가 앞서 가는 기계(또는 시계를 되돌린 뒤
|
|
3282
|
+
* 이어 쓴 전사)에서는 **마지막 발화가 자기 파일의 mtime 보다 뒤**일 수 있어 그 함의가 깨진다.
|
|
3283
|
+
* ★ 깨지는 쪽이 **닫는 쪽**이라 그대로 둔다: 그때 우리는 올릴 수 있는 것을 안 올릴 뿐(`before_consent`),
|
|
3284
|
+
* 못 올릴 것을 올리지는 않는다. 되찾기 훑기(`autoCandidates()` 의 `st.mtimeMs >= floor`)도 같은 방향으로
|
|
3285
|
+
* 떨어진다. 「왜 이건 안 올라갔나」의 답이 되는 자리라 부록 D-27 에 적는다.
|
|
3286
|
+
*/
|
|
3287
|
+
function autoHeadFill(c) {
|
|
3288
|
+
if (c.head_ms !== undefined) return c.head_ms;
|
|
3289
|
+
const head = transcriptHead(c.file);
|
|
3290
|
+
c.head_ms = head ? Number(head.ms) || 0 : 0; // 못 읽으면 0 — 나이를 모르는 것으로 두고 하한으로 안 막는다
|
|
3291
|
+
return c.head_ms;
|
|
3292
|
+
}
|
|
3293
|
+
|
|
3294
|
+
/**
|
|
3295
|
+
* `too_old` 표가 선 후보는 **거르기 전에** 머리를 채운다(호환 C2).
|
|
3296
|
+
*
|
|
3297
|
+
* ★ **이 함수가 없으면 「자라면 다시 후보」가 영영 안 돈다.** 판정(`autoSkipReason()`)은 `head_ms` 로 묻는데
|
|
3298
|
+
* 후보를 세우는 자리(`autoCandidates()`)는 그 칸을 안 채우고, 머리를 읽는 두 자리는 **판정보다 뒤**에 있었다.
|
|
3299
|
+
* 안 채운 칸은 `Number(undefined) = NaN` 이라 「하한을 안 넘었다」로 읽혀 그 자리에서 `continue` 했고,
|
|
3300
|
+
* 그래서 머리는 한 번도 안 읽혔다 — 409 한 번이 그 uuid 의 자동 올리기를 그 기계에서 **영구히** 껐다.
|
|
3301
|
+
* ⚠ 그렇다고 심박마다 후보의 꼬리 256KB 를 다시 읽지는 않는다: 표를 세울 때 그때의 mtime 을 함께 적어 두고
|
|
3302
|
+
* (`auto.too_old_mtime`), 파일이 **그대로면** 머리도 그대로이므로 안 읽는다. 견줌은 `>` 가 아니라 `!==` 다 —
|
|
3303
|
+
* 시계가 되돌아가 mtime 이 줄어든 것도 「바뀐 것」이다(호환 C8 과 같은 태도).
|
|
3304
|
+
* ⚠ 표가 옛 판이 적은 것이라 mtime 칸이 없으면(0) 언제나 읽는다 — 그 편이 스스로 낫는다.
|
|
3305
|
+
* ★★ **(호환 N8) 읽었는데도 여전히 동의 앞이면 그 mtime 을 표에 다시 적는다.** 전에는 표의 mtime 이 409 를 받은
|
|
3306
|
+
* 그 한 번에만 적혀서, 그 뒤로 파일이 **발화 아닌 줄**로 자라거나(요약·메타 줄) 손만 닿거나, 후보를 잰 뒤 올리기
|
|
3307
|
+
* 직전 사이에 자라면 mtime 이 표와 영영 달라 **심박마다** 꼬리 256KB 를 다시 읽었다. 이제 한 번 읽고 「그대로 낡았다」면
|
|
3308
|
+
* 그 mtime 이 새 표시다 — 파일이 다시 바뀔 때까지 안 읽는다. 머리를 못 읽은 경우(0)도 같다(바뀌면 다시 본다).
|
|
3309
|
+
* 적는 것은 **같은 `too_old` 값이 아직 파일에 있을 때만**이다(잠금 안의 견줌 — 옆 창이 그 사이 올려 표를 지웠으면 안 적는다).
|
|
3310
|
+
* @param {object} c 후보
|
|
3311
|
+
* @param {(uuid:string, tooOld:number, mtime:number) => unknown} [mark] 표시를 적는 자 — 시험은 파일 대신 받아 적는다
|
|
3312
|
+
*/
|
|
3313
|
+
function autoTooOldRefresh(c, mark = autoTooOldMark) {
|
|
3314
|
+
const a = c?.row?.auto;
|
|
3315
|
+
const tooOld = Number(a?.too_old) || 0;
|
|
3316
|
+
if (!(tooOld > 0)) return;
|
|
3317
|
+
const was = Number(a.too_old_mtime) || 0;
|
|
3318
|
+
const mtime = Number(c?.mtime) || 0;
|
|
3319
|
+
if (was > 0 && mtime === was) return;
|
|
3320
|
+
autoHeadFill(c);
|
|
3321
|
+
if (mtime > 0 && !(Number(c.head_ms) > 0 && Number(c.head_ms) >= tooOld)) mark(c.uuid, tooOld, mtime);
|
|
3322
|
+
}
|
|
3323
|
+
|
|
3324
|
+
/** 호환 N8 — `too_old` 표의 mtime 칸만 고친다. 파일의 그 행이 **같은 `too_old`** 를 쥐고 있을 때만(잠금 안에서 본다). */
|
|
3325
|
+
function autoTooOldMark(uuid, tooOld, mtime) {
|
|
3326
|
+
if (!AUTO_UUID_RE.test(String(uuid || ''))) return false;
|
|
3327
|
+
try {
|
|
3328
|
+
return indexWrite(uuid, null, { auto: { too_old_mtime: mtime } }, '', (row) => Number(row?.auto?.too_old) === tooOld);
|
|
3329
|
+
} catch { return false; }
|
|
3330
|
+
}
|
|
3331
|
+
|
|
3332
|
+
/**
|
|
3333
|
+
* 후보 하나를 거른다(§5.5 3) — 빈 문자열이면 올려도 된다. **순수 함수**(시험이 표로 잰다).
|
|
3334
|
+
* @param {{uuid:string, entry:?object, private_file?:boolean, row?:?object, raw_sha?:string, mtime?:number, head_ms?:number}} c
|
|
3335
|
+
* @param {{now:number, env_on:boolean, shas?:Map<string,string>, holds?:Set<string>, holds_ok?:boolean}} ctx
|
|
3336
|
+
* shas: 색인의 raw_sha → 그 uuid · holds: 색인 밖 보류 명부(§5.8) · holds_ok=false 면 전부 보류
|
|
3337
|
+
*/
|
|
3338
|
+
function autoSkipReason(c, ctx) {
|
|
3339
|
+
const e = c?.entry;
|
|
3340
|
+
if (!e || e.state !== 'bound' || e.auto_upload !== true) return 'not_bound_auto'; // ambiguous·unbound·자동 꺼짐 전부
|
|
3341
|
+
if (e.stale) return 'stale';
|
|
3342
|
+
if (c.private_file) return 'private';
|
|
3343
|
+
if (!ctx.env_on) return 'env_off';
|
|
3344
|
+
if (ctx.holds_ok === false) return 'holds_unreadable'; // 무엇이 보류인지 모르면 아무것도 안 올린다(닫는 쪽으로 실패)
|
|
3345
|
+
if (ctx.holds?.has?.(String(c.uuid || '').toLowerCase())) return 'hold';
|
|
3346
|
+
const row = c.row && typeof c.row === 'object' ? c.row : {};
|
|
3347
|
+
if (row.origin === 'pull') return 'pull'; // 받아 놓은 남의 사본은 폴더 소속의 증거가 아니다(§5.1)
|
|
3348
|
+
// 그 대화가 **다른 소속처**에 산다(conflict·moved) — 옮기는 것은 사람이다(§5.4 conflict). 같은 소속처면 갱신 대상.
|
|
3349
|
+
if (Number(row.tenant_id) > 0 && Number(row.tenant_id) !== Number(e.tenant_id)) return 'elsewhere';
|
|
3350
|
+
if (row.auto?.hold === true) return 'hold';
|
|
3351
|
+
if (Number(row.auto?.next_at) > ctx.now) return 'backoff';
|
|
3352
|
+
/*
|
|
3353
|
+
* ★★ **사람이 일부러 뺀 대화는 24시간 백오프가 아니라 그 소속처에 붙박이로 뺀다**(개인정보 NEW-4).
|
|
3354
|
+
* `session_hidden`(사람이 포털에서 가렸다·걷었다)과 `session_hold`(no_auto_push)는 **사람의 손**이 세운
|
|
3355
|
+
* 답이라 내일도 같은 답이다. 그런데 이 둘이 일반 24시간 백오프를 타는 동안, 서버가 관문 앞에서 막아
|
|
3356
|
+
* 아무것도 안 남기더라도 **검열된 전사 전체가 날마다 그 소속처 서버로 보내지고 파싱됐다** — 뺀 사람이
|
|
3357
|
+
* 원한 것의 반대다. 그래서 `too_old` 와 같은 대우를 한다(§5.5 7 ★).
|
|
3358
|
+
* ⚠ **소속처를 함께 적는다**: 막은 것은 그 소속처의 사람이다. 폴더가 다른 소속처로 확정되면 그 답은
|
|
3359
|
+
* 이 uuid 에 대해 아무 말도 한 적이 없으므로 표가 안 선다.
|
|
3360
|
+
* ⚠ **푸는 길**(§5.5 7): ① 그 uuid 를 사람이 `push_session` 으로 **그 소속처에** 올리면 지운다(성공한 올리기가
|
|
3361
|
+
* `autoClearOnPush()` 로 이 칸을 비운다 — 가리기·걷기는 그 길에서 실제로 풀린다. **다른** 소속처로 올린 것은
|
|
3362
|
+
* 이 표를 못 지운다 — 호환 N9), ② 성공한 자동 올리기도 같이 지운다, ③ 폴더가 다른 소속처로 바뀌면 위 소속처
|
|
3363
|
+
* 견줌이 안 맞아 저절로 안 선다, ④ (N10) 그 소속처의 resolve 가 「살아 있고 보류 없음」(`same`·`hold:false`)을
|
|
3364
|
+
* 답하면 푼다(`autoBlockFromResolve()` — 포털에서 가림·보류를 푼 사실이 이 기계에 닿는 길, 부록 D-30).
|
|
3365
|
+
* ⚠ 옛 서버(`hold` 칸 없음)·`none` 답으로는 안 푼다 — 그때는 사람이 한 번 올리면 된다.
|
|
3366
|
+
*/
|
|
3367
|
+
const blocked = row.auto?.blocked;
|
|
3368
|
+
if (blocked && AUTO_STICKY_409.has(String(blocked.code))
|
|
3369
|
+
&& (!(Number(blocked.tenant_id) > 0) || Number(blocked.tenant_id) === Number(e.tenant_id))) return String(blocked.code);
|
|
3370
|
+
// (판 8 — 적합성 F7) 파일의 표가 다른 소속처의 것이라 못 적은 막음 — 이 프로세스의 곁 표. 소속처가 같을 때만 선다.
|
|
3371
|
+
const aside = ctx.aside?.get?.(String(c.uuid || '').toLowerCase());
|
|
3372
|
+
if (aside && AUTO_STICKY_409.has(String(aside.code))
|
|
3373
|
+
&& Number(aside.tenant_id) > 0 && Number(aside.tenant_id) === Number(e.tenant_id)) return String(aside.code);
|
|
3374
|
+
/*
|
|
3375
|
+
* 동의 앞의 대화는 안 올린다(D3) — 잰 자리가 없는 후보(순수 시험의 표)는 이 줄을 안 탄다.
|
|
3376
|
+
* ① mtime 은 **싼 앞거르개**다: 파일이 하한보다 이르면 마지막 발화도 대개 그보다 이르다
|
|
3377
|
+
* (⚠ **불변식이 아니다** — 두 값을 다른 시계가 쓴다, 호환 C8 · `autoHeadFill()` 머리말).
|
|
3378
|
+
* ② 판정은 **마지막 발화**(head_ms)로 한다 — 서버 관문 11 이 재는 그 값이다(개인정보 N3).
|
|
3379
|
+
*/
|
|
3380
|
+
const floor = autoFloorMs(e);
|
|
3381
|
+
if (Number(c.mtime) > 0 && Number(c.mtime) < floor) return 'before_consent';
|
|
3382
|
+
if (Number(c.head_ms) > 0 && Number(c.head_ms) < floor) return 'before_consent';
|
|
3383
|
+
/*
|
|
3384
|
+
* ★ **서버가 「동의 앞이다」라고 답한 대화는 그 답이 바뀔 때까지 다시 안 두드린다**(개인정보 N3).
|
|
3385
|
+
* 전에는 409 → 24시간 백오프라 같은 헛걸음이 **날마다** 되풀이됐고, 그때마다 검열본이 서버 디스크에
|
|
3386
|
+
* 한 번 쓰였다 지워졌다. 대화가 **자라서** 마지막 발화가 그 하한을 넘어서면 그때는 다시 후보가 된다.
|
|
3387
|
+
*/
|
|
3388
|
+
const tooOld = Number(row.auto?.too_old) || 0;
|
|
3389
|
+
if (tooOld > 0 && !(Number(c.head_ms) > 0 && Number(c.head_ms) >= tooOld)) return 'too_old';
|
|
3390
|
+
if (c.raw_sha && ctx.shas?.has(c.raw_sha) && ctx.shas.get(c.raw_sha) !== c.uuid) return 'dup';
|
|
3391
|
+
return '';
|
|
3392
|
+
}
|
|
3393
|
+
|
|
3394
|
+
/** §5.5 4 — 내 세션 먼저, 그다음 전사가 최근에 움직인 순. */
|
|
3395
|
+
function autoOrder(list) {
|
|
3396
|
+
return list.slice().sort((x, y) => (y.mine ? 1 : 0) - (x.mine ? 1 : 0) || (Number(y.mtime) || 0) - (Number(x.mtime) || 0));
|
|
2081
3397
|
}
|
|
2082
3398
|
|
|
2083
|
-
/**
|
|
2084
|
-
function
|
|
2085
|
-
if (
|
|
2086
|
-
|
|
2087
|
-
|
|
3399
|
+
/** §5.5 7 — 실패 뒤 다음 시도까지(ms). `fails` 는 이번 실패까지 센 수(1부터). */
|
|
3400
|
+
function autoBackoffMs(fails, status, retryAfter = 0) {
|
|
3401
|
+
if (status === 429 && Number(retryAfter) > 0) return Number(retryAfter) * 1000;
|
|
3402
|
+
if (status === 403 || status === 409 || status === 422 || status === 426) return AUTO_BACKOFF_LONG_MS;
|
|
3403
|
+
return Math.min(AUTO_BACKOFF_CAP_MS, AUTO_BACKOFF_BASE_MS * 2 ** Math.max(0, (Number(fails) || 1) - 1));
|
|
3404
|
+
}
|
|
3405
|
+
|
|
3406
|
+
const autoShaMemo = new Map(); // 전사 → { size, mtimeMs, sha } — 크기+mtime 로 기억해 두 번 안 잰다(§5.5)
|
|
3407
|
+
function rawShaOf(file) {
|
|
3408
|
+
let st;
|
|
3409
|
+
try { st = fs.statSync(file); } catch { return ''; }
|
|
3410
|
+
const m = autoShaMemo.get(file);
|
|
3411
|
+
if (m && m.size === st.size && m.mtimeMs === st.mtimeMs) return m.sha;
|
|
3412
|
+
let sha = '';
|
|
3413
|
+
try { sha = crypto.createHash('sha256').update(fs.readFileSync(file)).digest('hex'); } catch { return ''; }
|
|
3414
|
+
autoShaMemo.set(file, { size: st.size, mtimeMs: st.mtimeMs, sha });
|
|
3415
|
+
return sha;
|
|
3416
|
+
}
|
|
3417
|
+
|
|
3418
|
+
const autoFolderMemo = new Map(); // 폴더 realpath → { at, fo } — git 을 심박마다 부르지 않는다
|
|
3419
|
+
function autoFolderOf(real, now = Date.now()) {
|
|
3420
|
+
const m = autoFolderMemo.get(real);
|
|
3421
|
+
if (m && now - m.at < AUTO_SCAN_EVERY_MS) return m.fo;
|
|
3422
|
+
const fo = folderOf(real);
|
|
3423
|
+
autoFolderMemo.set(real, { at: now, fo });
|
|
3424
|
+
return fo;
|
|
3425
|
+
}
|
|
3426
|
+
|
|
3427
|
+
let autoScan = { at: 0, rows: [] }; // 되찾기 훑기의 마지막 결과 — [{ uuid, file, mtime }]
|
|
3428
|
+
const autoSettled = new Map(); // uuid → 'size:mtime' — 서버가 SAME·BEHIND 라 한 사본(파일이 바뀌면 다시 본다)
|
|
3429
|
+
const autoTenantWait = new Map(); // tenant_id → epoch ms — 429 auto_rate 의 retry_after(그 상한은 소속처 단위다)
|
|
3430
|
+
|
|
3431
|
+
/** 전사 자리(transcriptDir) → 그 자리를 물었던 폴더의 답. resolve 캐시의 `by_path` 가 유일한 재료다. */
|
|
3432
|
+
function autoFolderMap(cache = resolveCacheRead(), now = Date.now()) {
|
|
3433
|
+
const byDir = new Map();
|
|
3434
|
+
for (const [real, key] of Object.entries(cache.by_path || {})) {
|
|
3435
|
+
const entry = resolveCache(key, cache, now);
|
|
3436
|
+
if (entry) byDir.set(transcriptDir(real), { real, key, entry });
|
|
3437
|
+
}
|
|
3438
|
+
return byDir;
|
|
2088
3439
|
}
|
|
2089
3440
|
|
|
2090
|
-
/* 자동 올리기 — **서버 사본이 낡았을 때만.** 사용자 결정(2026-08-20)이라 기본이 켜짐이고,
|
|
2091
|
-
* 끄는 길을 함께 둔다(`GENETICS_NO_AUTO_PUSH=1`). 이 값은 「내부 대화가 서버에 쌓인다」를
|
|
2092
|
-
* 뜻하므로, 끄는 길이 없으면 결정이 아니라 함정이 된다. */
|
|
2093
3441
|
/**
|
|
2094
|
-
* 이
|
|
2095
|
-
*
|
|
2096
|
-
*
|
|
2097
|
-
* 냈다(2026-08-12) — 오버라이드가 안 먹는 설정은 안전한 기본값이 아니라 함정이다.
|
|
3442
|
+
* §5.5 2 — 후보 모으기: ⓐ 이 세션 ⓑ 이 기계에 전사가 있는 색인 행(origin pull 제외) ⓒ 확정·자동 폴더의 전사 자리
|
|
3443
|
+
* 되찾기 훑기(색인 밖 · mtime 30일 안 · 폴더당 200개 · 5분에 한 번). 이을 폴더 답이 없는 후보는 뺀다.
|
|
3444
|
+
* Cursor 전사는 줄에 자리(cwd)가 없어 서버 관문 6 을 못 지난다 — 후보가 아니다.
|
|
2098
3445
|
*/
|
|
2099
|
-
function
|
|
2100
|
-
const
|
|
2101
|
-
if (
|
|
2102
|
-
|
|
2103
|
-
|
|
2104
|
-
|
|
2105
|
-
|
|
3446
|
+
function autoCandidates(now = Date.now()) {
|
|
3447
|
+
const byDir = autoFolderMap(resolveCacheRead(), now);
|
|
3448
|
+
if (!byDir.size) return [];
|
|
3449
|
+
const idx = indexRead();
|
|
3450
|
+
const mine = mySessionId();
|
|
3451
|
+
const seen = new Map();
|
|
3452
|
+
const add = (uuid, file) => {
|
|
3453
|
+
if (!file || seen.has(uuid) || isCursorTranscript(file)) return;
|
|
3454
|
+
const hit = byDir.get(path.dirname(file));
|
|
3455
|
+
if (!hit) return;
|
|
3456
|
+
let st;
|
|
3457
|
+
try { st = fs.statSync(file); } catch { return; }
|
|
3458
|
+
const row = idx[uuid] && typeof idx[uuid] === 'object' ? idx[uuid] : null;
|
|
3459
|
+
seen.set(uuid, { uuid, file, mine: uuid === mine, size: st.size, mtime: st.mtimeMs, real: hit.real, entry: hit.entry, row });
|
|
3460
|
+
};
|
|
3461
|
+
if (AUTO_UUID_RE.test(mine || '')) add(mine, transcriptOf(mine));
|
|
3462
|
+
for (const [uuid, row] of Object.entries(idx)) {
|
|
3463
|
+
if (!AUTO_UUID_RE.test(uuid) || !row || typeof row !== 'object' || row.origin === 'pull') continue;
|
|
3464
|
+
add(uuid, transcriptOf(uuid));
|
|
2106
3465
|
}
|
|
2107
|
-
|
|
3466
|
+
if (now - autoScan.at >= AUTO_SCAN_EVERY_MS) {
|
|
3467
|
+
const rows = [];
|
|
3468
|
+
for (const [dir, hit] of byDir) {
|
|
3469
|
+
if (hit.entry.state !== 'bound' || hit.entry.auto_upload !== true || hit.entry.stale) continue;
|
|
3470
|
+
// 되짚어 올리지 않는다(D3) — 동의 시각보다 앞선 전사는 훑기에서 아예 안 집는다(거르개도 다시 본다).
|
|
3471
|
+
const floor = autoFloorMs(hit.entry);
|
|
3472
|
+
let names;
|
|
3473
|
+
try { names = fs.readdirSync(dir); } catch { continue; }
|
|
3474
|
+
const found = [];
|
|
3475
|
+
for (const n of names) {
|
|
3476
|
+
const u = n.endsWith('.jsonl') ? n.slice(0, -6) : '';
|
|
3477
|
+
if (!AUTO_UUID_RE.test(u)) continue;
|
|
3478
|
+
const f = path.join(dir, n);
|
|
3479
|
+
let st;
|
|
3480
|
+
try { st = fs.statSync(f); } catch { continue; }
|
|
3481
|
+
if (st.isFile() && st.mtimeMs >= floor && now - st.mtimeMs <= AUTO_SCAN_MAX_AGE_MS) found.push({ uuid: u, file: f, mtime: st.mtimeMs });
|
|
3482
|
+
}
|
|
3483
|
+
found.sort((x, y) => y.mtime - x.mtime);
|
|
3484
|
+
rows.push(...found.slice(0, AUTO_SCAN_PER_FOLDER));
|
|
3485
|
+
}
|
|
3486
|
+
autoScan = { at: now, rows };
|
|
3487
|
+
}
|
|
3488
|
+
for (const r of autoScan.rows) if (!idx[r.uuid]) add(r.uuid, r.file);
|
|
3489
|
+
const out = [...seen.values()];
|
|
3490
|
+
for (const c of out) {
|
|
3491
|
+
if (c.entry.state !== 'bound' || c.entry.auto_upload !== true) continue;
|
|
3492
|
+
const fo = autoFolderOf(c.real, now);
|
|
3493
|
+
c.private_file = privateFileFor(c.real, c.entry.kind === 'git' && fo.git ? fo.git.rootReal : c.real);
|
|
3494
|
+
}
|
|
3495
|
+
return out;
|
|
3496
|
+
}
|
|
3497
|
+
|
|
3498
|
+
function autoContext(now = Date.now()) {
|
|
3499
|
+
const shas = new Map();
|
|
3500
|
+
for (const [u, row] of Object.entries(indexRead())) {
|
|
3501
|
+
if (AUTO_UUID_RE.test(u) && row && typeof row.raw_sha === 'string' && row.raw_sha !== '' && !shas.has(row.raw_sha)) shas.set(row.raw_sha, u);
|
|
3502
|
+
}
|
|
3503
|
+
const h = autoHoldsRead(); // 색인 밖 보류 명부 — 못 읽으면 이 바퀴는 아무것도 안 올린다(H2)
|
|
3504
|
+
// 닫는 쪽으로 실패하되 **조용히는 아니다** — 이 상태가 이어지면 자동 올리기가 영영 안 돈다. 사람이 고칠 수 있게 한 번 말한다.
|
|
3505
|
+
if (!h.ok) warnOnce('auto-holds-unreadable', `${HOLDS_FILE} 를 못 읽어 이 기계의 자동 올리기를 전부 멈춘다(무엇이 보류인지 모르므로). 그 파일을 고치거나 지우면 다시 돈다.`);
|
|
3506
|
+
return { now, env_on: autoPushOn(), shas, holds: h.set, holds_ok: h.ok, aside: autoBlockAside };
|
|
3507
|
+
}
|
|
3508
|
+
|
|
3509
|
+
/** 서버 최소 판보다 낡았는가 — 그러면 자동 올리기를 안 두드린다(서버가 426 으로 막을 자리다, §4.3 관문 2). */
|
|
3510
|
+
function autoClientOld() {
|
|
3511
|
+
return /^\d+\.\d+\.\d+/.test(LAST_MIN_CLIENT) && versionLess(VERSION, LAST_MIN_CLIENT);
|
|
2108
3512
|
}
|
|
2109
3513
|
|
|
2110
3514
|
/**
|
|
2111
|
-
*
|
|
2112
|
-
*
|
|
2113
|
-
*
|
|
2114
|
-
*
|
|
2115
|
-
*
|
|
2116
|
-
*
|
|
3515
|
+
* 이 색인 행이 **서버에 있던 대화**인가 — 첫 업로드를 건너뛸지 가르는 자.
|
|
3516
|
+
*
|
|
3517
|
+
* ★ **소속 칸만으로는 모른다.** `tenant_id` 는 서버를 거치지 않은 길로도 적힌다:
|
|
3518
|
+
* ① `mail` 도구가 받을 곳을 기억하며 적는다(:6523 — 한 번도 안 올린 대화도 메일을 먼저 쓸 수 있다)
|
|
3519
|
+
* ② 옛 판(≤2.18)의 로컬 입양이 남긴 행(`{tenant_id, head_id:'', head_ms:0}`)
|
|
3520
|
+
* 그 행을 「서버가 아는 것」으로 읽으면 그 대화는 **첫 자동 업로드에서 영영 빠진다** — 심박은 UNKNOWN 을
|
|
3521
|
+
* 돌려주는데 첫 업로드 자리가 건너뛰고, 옛 길(autoPushIfAhead)은 AHEAD 만 올리기 때문이다.
|
|
3522
|
+
* 2026-09-15 실측(hnl-2d): 이 기계의 HNL 세션 d082b7e8 이 색인에 소속처 22 를 갖고도 서버 hnl 목록 11건에 없다.
|
|
3523
|
+
* ★ 서버를 거친 행은 언제나 **머리(head_id)** 를 갖거나 **보낸 시각(tx_at)** 을 갖는다. `head_id` 를 색인에 적는 자는
|
|
3524
|
+
* 넷뿐이고 전부 서버 응답이다 — push(:1992·:2028)·list(:6362)·resume(:6441)·pull(:6504). `tx_at` 은
|
|
3525
|
+
* `indexNote(..., 'tx')` 가 찍는데 부르는 자리가 push 둘뿐이다(:1992·:2028).
|
|
3526
|
+
* ⚠⚠ **`rx_at` 은 이 판정에 쓰면 안 된다**(2026-09-15 hnl-2d 2차 점검 · 내가 코드로 확인). 그 칸은 「이 행이 무언가를
|
|
3527
|
+
* **받았다**」는 뜻이지 「전사가 서버에 있다」가 아니다 — 두뇌 커서(:5155 `fresh.length ? 'rx' : ''`)와 메일 커서
|
|
3528
|
+
* (:5458 `rows.length ? 'rx' : ''`)가 **내 세션 행에** 찍는다. 그래서 `mail` 로 소속이 적힌 세션이 답장을 한 통만
|
|
3529
|
+
* 받아도, 옛 입양 행이 review 이벤트를 한 건만 받아도 참이 되어 **고치려던 그 증상(첫 업로드 영구 제외)이 그대로
|
|
3530
|
+
* 난다.** 실측(재점검 시험대, 실제 autoBeatRecs·autoUploadRound, UNKNOWN 주입): new_mail_rx·new_brain_rx·
|
|
3531
|
+
* old_adopt_brain_rx·old_adopt_mail_rx 넷 다 put=0 · 대조군(rx 없음) new_mail·old_adopt 는 put=1.
|
|
3532
|
+
* ⚠ 계약 §5.1 표가 `rx_at` 의 쓰는 자를 「업로드/내려받기」로 적은 것도 그래서 좁다 — 커서도 찍는다.
|
|
3533
|
+
* ⚠ 가림·걷음을 기계가 되돌리지 않는다는 규율은 그대로다(§4.6) — 그 대화는 **여기서 참**이라 건너뛴다.
|
|
2117
3534
|
*/
|
|
2118
|
-
function
|
|
2119
|
-
|
|
2120
|
-
|
|
2121
|
-
return autoPushFromText(text, process.env);
|
|
3535
|
+
function autoRowOnServer(row) {
|
|
3536
|
+
if (!row || !(Number(row.tenant_id) > 0)) return false;
|
|
3537
|
+
return String(row.head_id || '') !== '' || Number(row.tx_at) > 0;
|
|
2122
3538
|
}
|
|
2123
3539
|
|
|
2124
|
-
|
|
2125
|
-
*
|
|
2126
|
-
*
|
|
2127
|
-
* 그 침묵의 값을 2026-08-23 에 쟀다: 이 기계의 세션이 **하루 넘게**(8-21 14:20, 6,094줄에서
|
|
2128
|
-
* 멈춤 — 로컬은 10,476줄) 서버에 안 올라갔고, 본문이 없으니 두뇌가 위키를 못 굽고,
|
|
2129
|
-
* 그래서 그날 배운 것이 팀 기억에 **한 장도** 안 남았다. 아무도 그 사실을 몰랐다 —
|
|
2130
|
-
* 파고들어야 보였다.
|
|
2131
|
-
*
|
|
2132
|
-
* 이 저장소는 세포를 필수로 만들며 규칙을 세웠다: **「없으면 막지 말고 알려주면서 유도한다」.**
|
|
2133
|
-
* 같은 규칙이 여기에도 걸린다. 끄는 것은 사람의 선택이지만, **끈 줄 모르는 것은 선택이 아니다.**
|
|
3540
|
+
/**
|
|
3541
|
+
* 판정을 받을 후보의 심박 레코드 — 이 기계가 서버 소속을 **모르는** 사본만(색인에 소속 칸이 있는 행은 beatTargets 가
|
|
3542
|
+
* 옛 규칙 그대로 싣는다). 거르개에 걸리는 것·이미 SAME 이던 사본·속도 제한에 걸린 소속처는 안 싣는다.
|
|
2134
3543
|
*/
|
|
2135
|
-
|
|
2136
|
-
|
|
2137
|
-
|
|
2138
|
-
|
|
2139
|
-
|
|
2140
|
-
|
|
2141
|
-
|
|
2142
|
-
|
|
2143
|
-
|
|
2144
|
-
|
|
3544
|
+
function autoBeatRecs(cands, ctx) {
|
|
3545
|
+
if (!ctx.env_on || autoClientOld()) return [];
|
|
3546
|
+
const out = [];
|
|
3547
|
+
for (const c of autoOrder(cands)) {
|
|
3548
|
+
if (autoRowOnServer(c.row)) continue; // 서버를 거친 행만 옛 길(beatTargets)에 맡긴다 — 소속 칸만 있는 행(mail·옛 입양)은 여기서 묻는다
|
|
3549
|
+
autoTooOldRefresh(c); // ★ 묻기 **전에** 머리를 채운다 — 안 그러면 「자라면 다시 후보」가 영영 안 돈다(호환 C2)
|
|
3550
|
+
if (autoSkipReason(c, ctx)) continue;
|
|
3551
|
+
if (autoSettled.get(c.uuid) === `${c.size}:${c.mtime}`) continue;
|
|
3552
|
+
if ((autoTenantWait.get(Number(c.entry.tenant_id)) || 0) > ctx.now) continue;
|
|
3553
|
+
const head = transcriptHead(c.file);
|
|
3554
|
+
if (!head) continue;
|
|
3555
|
+
// 머리를 읽었으면 그 값으로 **다시 거른다** — 동의 앞에 끝난 대화는 판정조차 물을 까닭이 없다(개인정보 N3).
|
|
3556
|
+
c.head_ms = Number(head.ms) || 0;
|
|
3557
|
+
if (autoSkipReason(c, ctx)) continue;
|
|
3558
|
+
out.push({
|
|
3559
|
+
uuid: c.uuid, device: deviceId(), head_id: head.id, head_ms: head.ms, lines: head.lines,
|
|
3560
|
+
tenant: Number(c.entry.tenant_id), flags: 0,
|
|
3561
|
+
state: ctx.now - c.mtime < BEAT_EVERY_MS ? BEAT_STATE.WORKING : BEAT_STATE.OPEN,
|
|
3562
|
+
});
|
|
3563
|
+
if (out.length >= BEAT.MAX) break;
|
|
3564
|
+
}
|
|
3565
|
+
return out;
|
|
3566
|
+
}
|
|
3567
|
+
|
|
3568
|
+
/**
|
|
3569
|
+
* 이 대화가 거쳐 간 자리 **전부**에 `.genetics-private` 이 있나(개인정보 검토 m1).
|
|
3570
|
+
* 최빈 자리 하나만 보면, 세션의 일부가 사적인 하위 폴더에서 돌아도 그 대화가 통째로 올라간다.
|
|
3571
|
+
*/
|
|
3572
|
+
function autoPrivateAnyCwd(sc, stopReal) {
|
|
3573
|
+
for (const raw of (Array.isArray(sc.cwds_raw) ? sc.cwds_raw : [])) {
|
|
3574
|
+
let r = String(raw || '');
|
|
3575
|
+
if (!r) continue;
|
|
3576
|
+
try { r = fs.realpathSync(r); } catch { /* 없는 자리면 준 값 그대로 */ }
|
|
3577
|
+
if (privateFileFor(r, stopReal)) return true;
|
|
3578
|
+
}
|
|
3579
|
+
return false;
|
|
3580
|
+
}
|
|
3581
|
+
|
|
3582
|
+
/** 보내기 직전 — 본문의 자리(최빈 cwd, realpath)가 그 확정 폴더 안인가. 맞으면 서버 관문 5·6 이 대조할 `folder` 를 낸다. */
|
|
3583
|
+
function autoPrepare(sc, c) {
|
|
3584
|
+
const e = c.entry;
|
|
3585
|
+
let real = String(sc.cwd_raw || '');
|
|
3586
|
+
if (!real) return { code: 'place_unreadable' };
|
|
3587
|
+
try { real = fs.realpathSync(real); } catch { /* 없는 자리면 준 값 그대로 */ }
|
|
3588
|
+
if (e.kind === 'git') {
|
|
3589
|
+
const fo = folderOf(real);
|
|
3590
|
+
if (!fo.git || fo.git.identity !== e.identity || !folderSubpathCovers(e.subpath, fo.git.subpath)) return { code: 'place_unreadable' };
|
|
3591
|
+
if (privateFileFor(real, fo.git.rootReal) || autoPrivateAnyCwd(sc, fo.git.rootReal)) return { code: 'private' };
|
|
3592
|
+
return { folder: { kind: 'git', identity: e.identity, subpath: e.subpath,
|
|
3593
|
+
path: folderMask(e.subpath ? path.join(fo.git.rootReal, e.subpath) : fo.git.rootReal) } };
|
|
3594
|
+
}
|
|
3595
|
+
const id = String(e.identity || '');
|
|
3596
|
+
const bound = id.slice(id.indexOf(':') + 1); // u<id>@<기기>:<마스킹 경로>
|
|
3597
|
+
if (folderMask(real) !== bound) return { code: 'place_unreadable' }; // path 바인딩은 정확 일치만(§3.4)
|
|
3598
|
+
if (privateFileFor(real, real) || autoPrivateAnyCwd(sc, real)) return { code: 'private' };
|
|
3599
|
+
return { folder: { kind: 'path', identity: id, subpath: '', path: bound } };
|
|
3600
|
+
}
|
|
3601
|
+
|
|
3602
|
+
/** 후보 하나를 올린다 — 머리 셋을 붙인 PUT(§4.3). 결과를 색인 `auto` 에 적는다(성공은 비우고, 실패는 물러선다). */
|
|
3603
|
+
async function autoUploadOne(c) {
|
|
3604
|
+
const e = c.entry;
|
|
3605
|
+
const u8 = c.uuid.slice(0, 8);
|
|
3606
|
+
try {
|
|
3607
|
+
const { r } = await pushSession(e.tenant_slug || e.tenant, c.uuid, '', false, 'skip',
|
|
3608
|
+
{ binding_id: Number(e.binding_id) || 0, device: deviceId(), prepare: (sc) => autoPrepare(sc, c) });
|
|
3609
|
+
indexPatch(c.uuid, { auto: { fails: 0, next_at: 0, last_code: '', ...autoClearOnPush(Number(r?.tenant_id) || e.tenant_id) } });
|
|
3610
|
+
process.stderr.write(`genetics: ${u8} 를 확정 폴더의 자동 올리기로 올렸다 — «${r.tenant || e.tenant}» · ${r.lines}줄.\n`);
|
|
3611
|
+
} catch (err) {
|
|
3612
|
+
const status = Number(err?.status) || 0;
|
|
3613
|
+
const code = String(err?.code || (status ? `http_${status}` : 'error'));
|
|
3614
|
+
const fails = (Number(indexRead()[c.uuid]?.auto?.fails) || 0) + 1;
|
|
3615
|
+
const now = Date.now();
|
|
3616
|
+
const wait = autoBackoffMs(fails, status, err?.retry_after);
|
|
3617
|
+
if (status === 429 && code === 'auto_rate') autoTenantWait.set(Number(e.tenant_id), now + wait);
|
|
3618
|
+
if (status === 426) minClientTick(true); // 판 알림 뒤 24시간(§5.5 7)
|
|
3619
|
+
/*
|
|
3620
|
+
* ★ **`too_old` 는 그 세션에 영구다**(개인정보 N3 · 부록 D-7). 「동의 앞에 끝난 대화」는 내일도 같은 답이다 —
|
|
3621
|
+
* 24시간 백오프로 두면 같은 검열본이 **날마다** 서버 디스크에 한 번 쓰였다 지워진다. 서버가 알려 준
|
|
3622
|
+
* 동의 시각을 적어 두고, 그 대화가 **자라서** 마지막 발화가 그 시각을 넘을 때까지 다시 안 보낸다.
|
|
3623
|
+
*/
|
|
3624
|
+
if (status === 409 && code === 'too_old') {
|
|
3625
|
+
const at = Date.parse(String(err?.auto_upload_at || '').replace(' ', 'T'));
|
|
3626
|
+
const floor = Number.isFinite(at) && at > 0 ? at : (autoFloorMs(e) || now);
|
|
3627
|
+
// ⚠ 그때의 mtime 을 함께 적는다 — 다음 바퀴부터는 **파일이 바뀌었을 때만** 머리를 다시 읽는다(호환 C2).
|
|
3628
|
+
indexPatch(c.uuid, { auto: { fails: 0, next_at: 0, last_code: code, too_old: floor, too_old_mtime: Number(c.mtime) || 0 } });
|
|
3629
|
+
warnOnce(`auto-too-old-${c.uuid}`,
|
|
3630
|
+
`${u8} 는 자동 올리기를 켜기 전에 끝난 대화라 안 올린다 — 다시 안 두드린다(올리려면 push_session).`);
|
|
3631
|
+
return;
|
|
3632
|
+
}
|
|
3633
|
+
/*
|
|
3634
|
+
* ★★ **사람이 뺀 대화는 물러섬이 아니라 붙박이다**(개인정보 NEW-4). `session_hidden`·`session_hold` 는
|
|
3635
|
+
* 사람의 손이 세운 답이라 24시간 뒤에도 같다 — 그런데 백오프로 두면 **검열된 전사 전체가 날마다**
|
|
3636
|
+
* 그 소속처 서버로 다시 보내지고 파싱된다(서버는 관문 앞에서 막으니 디스크에는 안 남는다 — 그래도
|
|
3637
|
+
* 보낸 것은 보낸 것이다). 소속처를 함께 적어 두고, 그 소속처에 대해서만 다시 안 보낸다.
|
|
3638
|
+
* 푸는 길은 `autoSkipReason()` 의 「푸는 길」 넷뿐이다(그 소속처로의 성공한 올리기 · 소속처 바뀜 · 그 소속처 resolve 의 `same`·`hold:false`).
|
|
3639
|
+
*/
|
|
3640
|
+
if (status === 409 && AUTO_STICKY_409.has(code)) {
|
|
3641
|
+
indexPatch(c.uuid, { auto: { fails: 0, next_at: 0, last_code: code } });
|
|
3642
|
+
// (판 8 — 적합성 F7) 표는 따로, 잠금 안 견줌으로 — 다른 소속처가 세운 표는 안 덮고 곁 표에 둔다.
|
|
3643
|
+
try { autoBlockPut(c.uuid, code, Number(e.tenant_id) || 0, now); } catch { /* 못 적으면 다음 바퀴의 409 가 다시 세운다 */ }
|
|
3644
|
+
warnOnce(`auto-blocked-${c.uuid}-${code}`, code === 'session_hidden'
|
|
3645
|
+
? `${u8} 는 사람이 «${e.tenant || e.tenant_slug}» 에서 가렸거나 걷은 대화라 자동으로 안 올린다 — 다시 안 두드린다(정말 올려야 하면 push_session).`
|
|
3646
|
+
: `${u8} 는 «${e.tenant || e.tenant_slug}» 에서 「자동으로 올리지 않음」으로 표시된 대화라 안 올린다 — 다시 안 두드린다(포털 「폴더」의 업로드 목록에서 푼다).`);
|
|
3647
|
+
return;
|
|
3648
|
+
}
|
|
3649
|
+
indexPatch(c.uuid, { auto: { fails, next_at: now + wait, last_code: code } });
|
|
3650
|
+
if (code === 'too_big') warnOnce(`auto-too-big-${c.uuid}`, `${u8} 는 검열본이 32MB 를 넘어 자동으로 올리지 않는다 — 올리려면 push_session.`);
|
|
3651
|
+
else beatSay(`auto-${c.uuid}-${code}`, `자동 올리기 보류(${u8} · ${code}) — ${err?.message || err}`);
|
|
3652
|
+
}
|
|
2145
3653
|
}
|
|
2146
|
-
const autoPushAt = new Map();
|
|
2147
3654
|
|
|
2148
|
-
|
|
3655
|
+
/** §5.5 5 — 심박 판정으로 갈라 한 바퀴에 한 건. 확정 폴더 밖의 앞선 사본은 옛 길(서버가 올려 달라고 할 때만). */
|
|
3656
|
+
async function autoUploadRound(reply, recs, cands) {
|
|
2149
3657
|
if (!autoPushOn()) { autoPushSilence(); return; }
|
|
2150
3658
|
if (!reply) return;
|
|
2151
|
-
const
|
|
3659
|
+
const ctx = autoContext();
|
|
3660
|
+
const verdict = new Map(reply.map((v) => [v.uuid, v]));
|
|
3661
|
+
for (const c of cands) {
|
|
3662
|
+
const v = verdict.get(c.uuid);
|
|
3663
|
+
if (v && (v.verdict === BEAT_VERDICT.SAME || v.verdict === BEAT_VERDICT.BEHIND)) autoSettled.set(c.uuid, `${c.size}:${c.mtime}`);
|
|
3664
|
+
}
|
|
3665
|
+
// ① 확정·자동 폴더 — 거르개를 전부 지난 첫 후보 하나(내 세션 → 최근 순)
|
|
3666
|
+
if (!autoClientOld()) {
|
|
3667
|
+
for (const c of autoOrder(cands)) {
|
|
3668
|
+
const v = verdict.get(c.uuid);
|
|
3669
|
+
if (!v || (v.flags & BEAT_MOVED)) continue; // 옮겨진 세션은 resolve 가 새 자리를 준다
|
|
3670
|
+
const first = v.verdict === BEAT_VERDICT.UNKNOWN;
|
|
3671
|
+
if (!first && v.verdict !== BEAT_VERDICT.AHEAD) continue; // SAME·BEHIND 는 안 올린다
|
|
3672
|
+
// 색인이 「서버에 있다」고 아는 대화가 UNKNOWN 이면 사람이 가렸거나 걷은 것이다 — **첫 업로드**가 아니다.
|
|
3673
|
+
// 다시 올리면 가림(deleted_at)이 풀린다(§4.6 — 지우는 손을 기계가 되돌리지 않는다).
|
|
3674
|
+
// ⚠ 「안다」의 자는 소속 칸이 아니라 **서버를 거친 표식**이다(autoRowOnServer) — mail 도구와 옛 판 입양이
|
|
3675
|
+
// 소속 칸만 적어서, 그 행을 여기서 건너뛰면 그 대화는 첫 업로드를 영영 못 한다(2026-09-15 실측).
|
|
3676
|
+
if (first && autoRowOnServer(c.row)) continue;
|
|
3677
|
+
/*
|
|
3678
|
+
* ★ **동의 문구가 먼저다**(개인정보 검토 M5·N8·N9). §5.4 「bound·auto on」 이 **그 폴더에 대해** 도구 응답
|
|
3679
|
+
* 꼬리로 한 번 나간 뒤에만 그 폴더의 전사를 올린다. 전에는 부팅 직후 첫 심박이 도구 호출보다 먼저 올려서,
|
|
3680
|
+
* genetics 도구를 한 번도 안 부른 세션이 **한 글자도 안 알린 채** 올라갔다.
|
|
3681
|
+
* ⚠ 깃발은 폴더별이고(N8: 창 하나가 두 확정 폴더를 오갈 수 있다), **옆 창의 전사에도 건다**
|
|
3682
|
+
* (N9: 후보의 대부분이 그것이다). 다만 옆 **대화 안에서는** 여전히 아무 말도 안 나간다 —
|
|
3683
|
+
* 그 한계는 `autoConsentSaid` 머리말과 §5.5 가 적어 둔다.
|
|
3684
|
+
*/
|
|
3685
|
+
if (!autoConsentGiven(c.entry)) continue;
|
|
3686
|
+
if (!first && ctx.now - (autoPushAt.get(c.uuid) || 0) < AUTO_PUSH_MIN_MS) continue;
|
|
3687
|
+
if ((autoTenantWait.get(Number(c.entry?.tenant_id)) || 0) > ctx.now) continue;
|
|
3688
|
+
autoTooOldRefresh(c); // ★ 묻기 **전에** 머리를 채운다(호환 C2) — 아래 autoHeadFill 은 이 뒤에 선다
|
|
3689
|
+
if (autoSkipReason(c, ctx)) continue;
|
|
3690
|
+
autoHeadFill(c); // 서버 관문 11 이 재는 값(마지막 발화)으로 한 번 더(N3)
|
|
3691
|
+
c.raw_sha = rawShaOf(c.file);
|
|
3692
|
+
if (autoSkipReason(c, ctx)) continue; // 동의 앞 · 이미 올린 다른 uuid 의 사본 — 다음 후보로
|
|
3693
|
+
if (!indexClaim(c.uuid, ctx.now + AUTO_PUSH_MIN_MS, ctx.now)) continue; // 옆 창이 먼저 잡았다
|
|
3694
|
+
autoPushAt.set(c.uuid, ctx.now);
|
|
3695
|
+
await autoUploadOne(c);
|
|
3696
|
+
return; // 한 바퀴에 한 건
|
|
3697
|
+
}
|
|
3698
|
+
}
|
|
3699
|
+
// ② 옛 길 — 확정 바인딩이 없는 폴더에서 사람이 올린 세션이 앞섰을 때(서버가 올려 달라고 할 때만)
|
|
2152
3700
|
for (const v of reply) {
|
|
2153
3701
|
if (v.verdict !== BEAT_VERDICT.AHEAD || !(v.flags & BEAT_WANT_PUSH)) continue;
|
|
3702
|
+
if (cands.some((c) => c.uuid === v.uuid && c.entry?.state === 'bound')) continue; // 확정 폴더의 행은 ① 의 거르개가 정한다
|
|
2154
3703
|
const rec = recs.find((r) => r.uuid === v.uuid);
|
|
2155
3704
|
if (!rec?._tenant_arg) continue;
|
|
2156
|
-
if (
|
|
2157
|
-
|
|
3705
|
+
if (ctx.holds_ok === false) continue; // 보류 명부를 못 읽으면 옛 길도 안 간다(H2)
|
|
3706
|
+
if (ctx.holds.has(String(v.uuid).toLowerCase())) continue; // no_auto_push — 색인이 절단돼도 남는 자리
|
|
3707
|
+
if (indexRead()[v.uuid]?.auto?.hold === true) continue; // no_auto_push — 이 대화는 자동으로 안 올라간다
|
|
3708
|
+
if (ctx.now - (autoPushAt.get(v.uuid) || 0) < AUTO_PUSH_MIN_MS) continue;
|
|
3709
|
+
autoPushAt.set(v.uuid, ctx.now);
|
|
2158
3710
|
try {
|
|
2159
|
-
const { r } = await pushSession(rec._tenant_arg, v.uuid, '');
|
|
3711
|
+
const { r } = await pushSession(rec._tenant_arg, v.uuid, '', false, 'skip');
|
|
2160
3712
|
process.stderr.write(`genetics: ${v.uuid.slice(0, 8)} 를 자동으로 올렸다 — ${r.lines}줄(서버가 낡아 있었다).\n`);
|
|
2161
3713
|
} catch (e) {
|
|
2162
3714
|
// 실패는 알리되 멈추지 않는다. 다음 심박이 다시 시도한다.
|
|
2163
3715
|
process.stderr.write(`genetics: 자동 올리기 실패(${v.uuid.slice(0, 8)}) — ${e.message}\n`);
|
|
2164
3716
|
}
|
|
3717
|
+
return; // 한 바퀴에 한 건
|
|
3718
|
+
}
|
|
3719
|
+
}
|
|
3720
|
+
|
|
3721
|
+
/** no_auto_push 가 서버 보류를 세울 소속처 — 그 대화가 사는 곳(색인, 서버가 준 값)과 그 폴더의 확정 소속처(resolve 캐시). */
|
|
3722
|
+
function autoHoldTenants(uuid) {
|
|
3723
|
+
const out = new Set();
|
|
3724
|
+
const row = indexRead()[uuid];
|
|
3725
|
+
if (Number(row?.tenant_id) > 0 && tenantArgOf(row)) out.add(tenantArgOf(row));
|
|
3726
|
+
const cache = resolveCacheRead();
|
|
3727
|
+
const hits = [];
|
|
3728
|
+
const f = transcriptOf(uuid);
|
|
3729
|
+
if (f && !isCursorTranscript(f)) {
|
|
3730
|
+
const hit = autoFolderMap(cache).get(path.dirname(f));
|
|
3731
|
+
if (hit) hits.push(hit.entry);
|
|
2165
3732
|
}
|
|
3733
|
+
if (uuid === mySessionId()) {
|
|
3734
|
+
let real = process.cwd();
|
|
3735
|
+
try { real = fs.realpathSync(real); } catch { /* */ }
|
|
3736
|
+
const e = resolveCache(cache.by_path?.[real], cache);
|
|
3737
|
+
if (e) hits.push(e);
|
|
3738
|
+
}
|
|
3739
|
+
for (const e of hits) if (e.state === 'bound' && (e.tenant_slug || e.tenant)) out.add(e.tenant_slug || e.tenant);
|
|
3740
|
+
return [...out];
|
|
2166
3741
|
}
|
|
2167
3742
|
|
|
2168
3743
|
/** 붙어 있는 에이전트(initialize 의 clientInfo). 세션 이식에서 「어느 도구가 만든 대화인가」다. */
|
|
@@ -2182,9 +3757,14 @@ async function beatTick() {
|
|
|
2182
3757
|
if (beatRunning) return;
|
|
2183
3758
|
beatRunning = true;
|
|
2184
3759
|
try {
|
|
2185
|
-
//
|
|
2186
|
-
|
|
2187
|
-
|
|
3760
|
+
// 폴더 소속은 서버가 정한다 — 때가 되면 resolve 캐시를 갱신한다(§5.3). 조기 반환보다 **앞**이다.
|
|
3761
|
+
try { await resolveTick(); } catch { /* 소속 확인 실패가 심박을 멈추면 안 된다 */ }
|
|
3762
|
+
minClientTick(); // 판 비교 — 30분에 한 번(§5.8). 조기 반환보다 **앞**이다
|
|
3763
|
+
// 확정·자동 폴더의 후보(§5.5) — 서버 소속을 이 기계가 모르는 사본도 판정을 받게 심박에 싣는다.
|
|
3764
|
+
let cands = [];
|
|
3765
|
+
let extra = [];
|
|
3766
|
+
try { cands = autoCandidates(); extra = autoBeatRecs(cands, autoContext()); } catch { /* 후보를 못 모아도 심박은 뛴다 */ }
|
|
3767
|
+
const recs = beatTargets(extra);
|
|
2188
3768
|
if (!recs.length) return;
|
|
2189
3769
|
// **안 바뀌었으면 안 보낸다.** 이 설계가 실제로 버는 것은 바이트가 아니라 이것이다.
|
|
2190
3770
|
const sig = recs.map((r) => `${r.uuid}:${r.head_id}:${r.lines}:${r.state}`).join('|');
|
|
@@ -2192,7 +3772,9 @@ async function beatTick() {
|
|
|
2192
3772
|
beatLastSig = sig;
|
|
2193
3773
|
beatLastSent = Date.now();
|
|
2194
3774
|
const reply = await beatOnce(recs);
|
|
2195
|
-
|
|
3775
|
+
// 옮겨진 세션은 resolve 가 새 자리를 준다(심박 프레임은 그대로 — §4.5).
|
|
3776
|
+
try { await resolveMoved(reply); } catch { /* 다음 바퀴에 다시 */ }
|
|
3777
|
+
try { await autoUploadRound(reply, recs, cands); } catch { /* 한 바퀴가 실패해도 심박은 계속 뛴다 */ }
|
|
2196
3778
|
// 두뇌가 짚은 모순을 긷는다 — 실패해도 심박을 안 멈춘다(안 오는 것이 고장은 아니다).
|
|
2197
3779
|
try { await brainPull(); } catch { /* 그물·자격 문제는 다음 바퀴에 다시 */ }
|
|
2198
3780
|
} finally {
|
|
@@ -2242,7 +3824,7 @@ function observeTick() {
|
|
|
2242
3824
|
if (SEND) notifyToolsChanged(SEND);
|
|
2243
3825
|
}
|
|
2244
3826
|
|
|
2245
|
-
/** 이 세션의 색인 항목이 아는 소속처 이름 — 없으면 ''.
|
|
3827
|
+
/** 이 세션의 색인 항목이 아는 소속처 이름 — 없으면 ''. 색인의 소속 칸은 서버가 준 값뿐이다(§5.1). 세포는 resolve 캐시를 먼저 본다(§6). */
|
|
2246
3828
|
function observeTenant() {
|
|
2247
3829
|
try {
|
|
2248
3830
|
const mine = mySessionId();
|
|
@@ -2379,6 +3961,8 @@ const TOOLS = [
|
|
|
2379
3961
|
type: 'object',
|
|
2380
3962
|
properties: {
|
|
2381
3963
|
id: { type: 'integer', description: '보고서 id' },
|
|
3964
|
+
tenant: { type: 'string', description: '대상 소속처 slug(웹에서 이어받은 요청에 포함).' },
|
|
3965
|
+
current_text: { type: 'boolean', description: '현재 문서의 전체 텍스트와 판을 읽는다. 64KiB 초과는 명시 실패, 이미지 제외. 기본 JSON 응답과 별도.' },
|
|
2382
3966
|
outline: {
|
|
2383
3967
|
type: 'boolean',
|
|
2384
3968
|
description: '참이면 데이터 대신 **렌더 결과의 구조**를 준다 — 블록 차례·챕터 좌우'
|
|
@@ -2473,7 +4057,8 @@ const TOOLS = [
|
|
|
2473
4057
|
name: 'release_report',
|
|
2474
4058
|
description:
|
|
2475
4059
|
'초안을 고객에게 연다. 이 순간부터 고객 목록과 열람에 뜬다. ' +
|
|
2476
|
-
'이미 열려 있으면 아무 일도 하지 않고 그렇다고 답한다(다시 불러도 안전하다)
|
|
4060
|
+
'이미 열려 있으면 아무 일도 하지 않고 그렇다고 답한다(다시 불러도 안전하다).\n' +
|
|
4061
|
+
'사람 자격으로 공개해야 보고서의 열린 항목이 일감으로 선다.',
|
|
2477
4062
|
inputSchema: {
|
|
2478
4063
|
type: 'object',
|
|
2479
4064
|
properties: {
|
|
@@ -2516,6 +4101,8 @@ const TOOLS = [
|
|
|
2516
4101
|
name: 'update_report',
|
|
2517
4102
|
description:
|
|
2518
4103
|
'기존 보고서의 내용을 교체한다. 보고서 id·주소가 그대로라 **이미 고객에게 나간 열람 링크가 새 내용을 가리킨다**. ' +
|
|
4104
|
+
'**노출 상태는 안 바꾼다**(2026-09-14) — 초안(release 전)을 고치면 초안 그대로고, 열린 문서를 고치면 열린 그대로다. ' +
|
|
4105
|
+
'초안을 여는 동사는 release_report 하나다. get_report 로 되읽은 데이터에 섞여 오는 publish·published_at 은 이 도구가 떼고 보낸다 — 그 값으로는 안 열린다. ' +
|
|
2519
4106
|
'사람이 편집기로 고친 문서도 교체할 수 있다(2026-08-25) — 대신 무엇을 덮게 되는지 목록이 먼저 말하고, 덮은 뒤 되돌릴 판이 응답에 온다. ' +
|
|
2520
4107
|
'**그림 계약**: `assets` 를 **빼고** 보내면 서버가 저장된 그림을 그대로 유지한다. 전부 지우려면 `assets: []` 를 명시하고 ' +
|
|
2521
4108
|
'`sections[].plates`·`chapters[].asset` 에서도 그 id 를 빼야 한다 — 가리키는 곳이 없는 id 는 422 로 거절된다. ' +
|
|
@@ -2618,7 +4205,7 @@ const TOOLS = [
|
|
|
2618
4205
|
reopen: { type: 'string', description: '무슨 조건이면 다시 여나. **비우면 「열린 결정」으로 읽힌다.** 끝났으면 "조건 없음(종결)".' },
|
|
2619
4206
|
status: { type: 'string', description: '상태 — "유효"(기본) · "뒤집힘 → decisions/<그 결정>" · "조건 충족 → <무엇이> (날짜)".' },
|
|
2620
4207
|
tags: { type: 'array', items: { type: 'string' }, description: '갈래(최대 8). ★ 이것이 있어야 이 결정이 「일」 후보 계산에 들어간다.' },
|
|
2621
|
-
work: { type: 'string', description: '**이 결정이 곧 과업이면** 그 일의 열쇠(영문 소문자·숫자·붙임표, 예: hnl-wait). 적으면
|
|
4208
|
+
work: { type: 'string', description: '**이 결정이 곧 과업이면** 그 일의 열쇠(영문 소문자·숫자·붙임표, 예: hnl-wait). 적으면 그 이름의 일이 **지금** 「계획」으로 선다 — 착수 전에 세우는 정문이다. 이미 열린 일의 열쇠면 이 결정이 그 일의 **항목**(작업 리스트 한 줄)으로 서고 간트에 저장 즉시 보인다 — 항목 여럿이면 결정을 여러 번 적는다(항목마다 제 근거를 갖는다).' },
|
|
2622
4209
|
order: { type: 'array', items: { type: 'string' }, description: '일 사이 **순서**를 정하는 결정이면 일의 열쇠를 앞선 것부터(최대 24). 표에 저장하지 않고 일 목록이 이 결정을 읽어 그 순서로 선다 — 순서가 바뀌면 새 결정을 적는다.' },
|
|
2623
4210
|
plan: { type: 'array', maxItems: 64, items: { type: 'object', required: ['item', 'from', 'to'], properties: {
|
|
2624
4211
|
item: { type: 'string', description: '항목의 결정 장 경로(예 decisions/W-01-….md) — works 가 보여 주는 항목 열쇠 그대로' },
|
|
@@ -2929,18 +4516,21 @@ const TOOLS = [
|
|
|
2929
4516
|
+ '받는 것은 부를 필요가 없다 — 온 것은 다른 도구를 부를 때 응답 꼬리에 저절로 실린다.\n'
|
|
2930
4517
|
+ '**사람에게는 이메일로** 보낸다(그 소속처 담당만) — 포털 벨이 울린다.\n'
|
|
2931
4518
|
+ '⚠ 상대 이름은 **지어내지 말 것.** `world` 나 `gcell agents` 가 아는 이름, 세션 uuid, '
|
|
2932
|
-
+ '또는 `brain` 중 하나다. 못 찾으면 사용자에게 묻는다.\n'
|
|
2933
|
-
+ '⚠
|
|
4519
|
+
+ '담당의 이메일, 또는 `brain` 중 하나다. 못 찾으면 사용자에게 묻는다.\n'
|
|
4520
|
+
+ '⚠ 한 실(thread)에는 상한이 있다 — **왕복**(한 쪽이 보내고 상대가 답하면 1왕복)은 사람이 낀 실 5회·기계끼리 3회, '
|
|
4521
|
+
+ '답 없이 같은 쪽이 연달아 부치는 것은 3통. 넘으면 409 로 거절된다. '
|
|
4522
|
+
+ '**사람에게 저절로 알리는 기능은 아직 없다** — 사람의 판단이 필요하면 이 세션의 사용자에게 말하거나 '
|
|
4523
|
+
+ '담당의 이메일로 thread 없이 보낸다.',
|
|
2934
4524
|
inputSchema: {
|
|
2935
4525
|
type: 'object',
|
|
2936
4526
|
properties: {
|
|
2937
4527
|
tenant: { type: 'string', description: '소속처 slug(이름도 받는다). 모르면 list_my_tenants 로 먼저 확인한다' },
|
|
2938
|
-
to: { type: 'string', description: '받는 쪽 — 세포 이름 · 세션 uuid · 또는 brain' },
|
|
4528
|
+
to: { type: 'string', description: '받는 쪽 — 세포 이름 · 세션 uuid · 담당의 이메일(사람에게) · 또는 brain' },
|
|
2939
4529
|
body: { type: 'string', description: '할 말' },
|
|
2940
4530
|
subject: { type: 'string', description: '한 줄 제목(선택)' },
|
|
2941
4531
|
artifact: { type: 'string', description: '무엇에 대한 이야기인가 — 파일·문서 경로(선택)' },
|
|
2942
4532
|
done_when: { type: 'string', description: '무엇이 되면 끝인가(선택)' },
|
|
2943
|
-
thread: { type: 'string', description: '답장이면 받은
|
|
4533
|
+
thread: { type: 'string', description: '답장이면 받은 글의 「· 실:」 줄에 찍힌 32자리 값을 그대로 — 같은 실로 이어진다. 새 이야기면 넣지 않는다(새 실이 선다)' },
|
|
2944
4534
|
},
|
|
2945
4535
|
/*
|
|
2946
4536
|
* ⚠⚠ **발신자를 여기 안 적는다.** `DESIGN-session-as-vcs.md` §5.1 —
|
|
@@ -2975,6 +4565,26 @@ const TOOLS = [
|
|
|
2975
4565
|
'사람이 「세포 지워」 「gcell 제거」라고 할 때 쓴다. 되돌리려면 `install_cell`.',
|
|
2976
4566
|
inputSchema: { type: 'object', properties: {} },
|
|
2977
4567
|
},
|
|
4568
|
+
{
|
|
4569
|
+
/*
|
|
4570
|
+
* 자동 올리기에서 **대화 하나를 뺀다**(DESIGN-genetics-ssot.md §5.8). 확정 폴더는 사람이 켠 뒤로 저절로 올라가므로
|
|
4571
|
+
* 대화 안에서 「이것만은 올리지 마」를 세울 손잡이가 있어야 한다. 소속처를 인자로 안 받는다 — 서버 표시는 이 기계의
|
|
4572
|
+
* 색인·resolve 캐시가 아는 곳으로 간다(모델이 지어낸 이름이 보류를 엉뚱한 곳에 세우지 않게).
|
|
4573
|
+
*/
|
|
4574
|
+
name: 'no_auto_push',
|
|
4575
|
+
description:
|
|
4576
|
+
'**이 대화를 자동 올리기에서 뺀다.** 사람이 포털 「폴더」에서 소속을 확정하고 자동 올리기를 켠 폴더의 대화는 ' +
|
|
4577
|
+
'검열 뒤 서버에 저절로 올라가는데, 그중 이 대화 하나만 안 올리고 싶을 때 쓴다.\n' +
|
|
4578
|
+
'사람이 「이 대화는 올리지 마」 「자동 업로드에서 빼」라고 할 때 부른다. uuid 를 안 주면 지금 이 세션이다.\n' +
|
|
4579
|
+
'이미 올라간 부분은 지우지 않는다 — 지우는 것은 포털 「폴더」의 업로드 목록에서 사람이 한다.',
|
|
4580
|
+
inputSchema: {
|
|
4581
|
+
type: 'object',
|
|
4582
|
+
properties: {
|
|
4583
|
+
uuid: { type: 'string', description: '뺄 세션 id(생략하면 이 세션).' },
|
|
4584
|
+
},
|
|
4585
|
+
additionalProperties: false,
|
|
4586
|
+
},
|
|
4587
|
+
},
|
|
2978
4588
|
];
|
|
2979
4589
|
|
|
2980
4590
|
/* 서버가 준 경고를 그대로 싣는다.
|
|
@@ -3165,52 +4775,286 @@ function worldFacts(max = 8) {
|
|
|
3165
4775
|
} catch { return null; }
|
|
3166
4776
|
}
|
|
3167
4777
|
|
|
3168
|
-
/**
|
|
4778
|
+
/** 폴더 소속 알림의 되풀이 간격 — **같은 상태는** 30분에 한 번. 상태가 바뀌면 바로 다시 말한다. */
|
|
3169
4779
|
const TENANT_NOTICE_MS = 30 * 60_000;
|
|
3170
|
-
|
|
4780
|
+
const tenantSaidAt = new Map(); // 상태 열쇠 → 마지막으로 말한 시각
|
|
3171
4781
|
|
|
3172
4782
|
/*
|
|
3173
|
-
*
|
|
3174
|
-
*
|
|
4783
|
+
* 폴더 소속 알림(DESIGN-genetics-ssot.md §5.4) — 변화분이 아니라 **이 자리의 상태**다.
|
|
4784
|
+
* 문구의 정본은 그 절의 표이고 여기는 글자 그대로 옮긴다(V3 가 글자 일치로 잰다).
|
|
4785
|
+
* `bound+auto` 는 동의의 표시라 그 세션의 첫 도구 응답 꼬리에 나가야 한다 — 답이 아직 없으면
|
|
4786
|
+
* 아무 말도 안 하고(되풀이 시계도 안 건드린다) 답이 온 뒤의 첫 응답이 말한다.
|
|
3175
4787
|
*
|
|
3176
|
-
* ⚠
|
|
3177
|
-
*
|
|
3178
|
-
|
|
3179
|
-
|
|
3180
|
-
|
|
3181
|
-
|
|
3182
|
-
|
|
3183
|
-
|
|
4788
|
+
* ⚠ 끄는 길 셋은 무게가 다르다 — 서버가 강제하는 것은 포털의 자동 올리기와 전역 스위치뿐이고,
|
|
4789
|
+
* `.genetics-private`·`GENETICS_NO_AUTO_PUSH` 는 이 기계의 이 클라이언트가 지키는 약속이다.
|
|
4790
|
+
*/
|
|
4791
|
+
function josaIGa(name) {
|
|
4792
|
+
const c = String(name || '').trim().slice(-1).charCodeAt(0);
|
|
4793
|
+
return c >= 0xac00 && c <= 0xd7a3 && (c - 0xac00) % 28 === 0 ? '가' : '이';
|
|
4794
|
+
}
|
|
4795
|
+
|
|
4796
|
+
/** 상태 → 문구. 순수 함수 — 시험이 표와 글자로 대조한다. 모르는 상태면 ''. */
|
|
4797
|
+
function tenantNoticeText(v) {
|
|
4798
|
+
if (!v || typeof v !== 'object') return '';
|
|
4799
|
+
const q = (s) => `«${s}»`;
|
|
4800
|
+
switch (v.state) {
|
|
4801
|
+
case 'bound': {
|
|
4802
|
+
const T = q(v.tenant || '');
|
|
4803
|
+
/*
|
|
4804
|
+
* ⚠ **여기 문구는 「지금 무엇이 일어나는가」를 말한다**(개인정보 M7·호환 M7). 상태가 겹치면 **막고 있는 것**을
|
|
4805
|
+
* 먼저 말한다 — 전역 스위치 → 폴더 스위치 → 사람이 건 약속(사적 파일·환경변수·보류) → 판 낡음 → 동의문.
|
|
4806
|
+
* 전에는 전역 스위치가 꺼져 있어도 「포털에서 사람이 켠다」고, 판이 낡아 한 글자도 안 올라가는데도
|
|
4807
|
+
* 「자동으로 올라간다」고 말했다 — 참이 아닌 안내는 안내가 아니라 함정이다.
|
|
4808
|
+
*/
|
|
4809
|
+
if (v.global_off) {
|
|
4810
|
+
return `genetics: 이 서버는 자동 올리기 전역 스위치가 꺼져 있어, 이 폴더(${T})의 설정과 무관하게 아무것도 안 올라간다 — `
|
|
4811
|
+
+ '포털 「폴더」에서 켜도 그대로다. 운영진이 서버 스위치를 켜야 그때부터 올라간다.';
|
|
4812
|
+
}
|
|
4813
|
+
if (!v.auto_upload) {
|
|
4814
|
+
return `genetics: 이 폴더는 ${T} 로 확정돼 있지만 자동 올리기가 꺼져 있다 — 이 대화는 서버에 안 쌓인다. `
|
|
4815
|
+
+ '올리려면 push_session, 늘 올리려면 포털 「폴더」에서 사람이 켠다.';
|
|
4816
|
+
}
|
|
4817
|
+
/*
|
|
4818
|
+
* ⚠ **사람이 서버에서 가린 대화에는 「올라간다」고 말하지 않는다**(개인정보 N10). 폴더는 켜져 있어도
|
|
4819
|
+
* 이 대화 하나는 서버가 막는다(§4.3 관문 7a). 이 사실은 resolve 의 그 uuid 상태로만 알 수 있다 —
|
|
4820
|
+
* 서버가 `session.state:"hidden"` 을 낼 때만 참이고(부록 D-1), 안 내는 서버에서는 이 갈래가 안 선다.
|
|
4821
|
+
* ⚠ **올라가기 전 보류(`why:"tomb"`)는 「가렸다」가 아니다** — 가린 사람이 없고, 「자동으로 올리지 않음」을 건
|
|
4822
|
+
* 사람이 있다. 푸는 자리도 다르다(업로드 목록이 아니라 「올라온 적 없는 보류」). 그래서 문장을 가른다.
|
|
4823
|
+
*/
|
|
4824
|
+
if (v.server_hidden) {
|
|
4825
|
+
return `genetics: 이 대화는 사람이 ${T} 에서 가렸거나 걷었다 — 이 대화는 기계가 다시 안 올린다`
|
|
4826
|
+
+ '(이 폴더의 다른 대화는 그대로 올라간다). 다시 올려야 하면 사람이 push_session 으로 올린다.';
|
|
4827
|
+
}
|
|
4828
|
+
if (v.server_tomb) {
|
|
4829
|
+
return `genetics: 이 대화는 올라가기 전에 ${T} 에서 「자동으로 올리지 않음」으로 표시됐다(no_auto_push) — 기계가 이 대화를 안 올린다`
|
|
4830
|
+
+ '(이 폴더의 다른 대화는 그대로 올라간다). 되돌리려면 포털 「폴더」의 「올라온 적 없는 보류」에서 치운다.';
|
|
4831
|
+
}
|
|
4832
|
+
if (v.private_file) {
|
|
4833
|
+
return `genetics: 이 폴더에 .genetics-private 이 있어 이 클라이언트는 자동으로 올리지 않는다(소속은 ${T} 로 확정돼 있고 `
|
|
4834
|
+
+ '서버 스위치는 켜져 있다 — 다른 기기·옛 판은 올릴 수 있다). 확실히 끊으려면 포털 「폴더」에서 끈다.';
|
|
4835
|
+
}
|
|
4836
|
+
if (v.env_off) {
|
|
4837
|
+
return 'genetics: GENETICS_NO_AUTO_PUSH 가 켜져 있어 이 기계는 자동으로 올리지 않는다 — 두뇌가 이 대화로 팀 기억을 못 굽는다. '
|
|
4838
|
+
+ '서버 스위치는 그대로다.';
|
|
4839
|
+
}
|
|
4840
|
+
if (v.hold) {
|
|
4841
|
+
if (v.hold_local) {
|
|
4842
|
+
return 'genetics: 이 대화는 「자동으로 올리지 않음」으로 표시돼 있다(no_auto_push). '
|
|
4843
|
+
+ '아직 서버에 올라간 적이 없어 그 표시는 **이 기계에만** 있다 — 다른 기기·옛 판은 이 대화를 올릴 수 있다. '
|
|
4844
|
+
+ '한 번이라도 올라간 뒤에는 포털 「폴더」의 업로드 목록에 서고, 그때부터 서버가 막는다.';
|
|
4845
|
+
}
|
|
4846
|
+
return 'genetics: 이 대화는 「자동으로 올리지 않음」으로 표시돼 있다(no_auto_push). '
|
|
4847
|
+
+ '되돌리려면 포털 「폴더」의 업로드 목록에서 푼다.';
|
|
4848
|
+
}
|
|
4849
|
+
if (v.client_old) {
|
|
4850
|
+
return `genetics: 이 폴더는 ${T} 로 확정돼 있고 자동 올리기도 켜져 있지만, 이 판(${v.version})이 서버 최소 판(${v.min_client})보다 낡아 `
|
|
4851
|
+
+ '**이 대화는 안 올라간다**. npx 캐시를 비우고 다시 깔면 그때부터 올라간다: rm -rf ~/.npm/_npx && npx -y genetics-mcp@latest.';
|
|
4852
|
+
}
|
|
4853
|
+
const who = v.confirmed_by ? `${v.confirmed_by}${josaIGa(v.confirmed_by)} ` : '';
|
|
4854
|
+
return `genetics: 이 세션은 ${T} 소속으로 확정된 폴더(${v.path} · ${who}${String(v.confirmed_at || '').slice(0, 10)} 확정, 자동 올리기 켬)에서 `
|
|
4855
|
+
+ '돌고 있어, 이 대화가 서버에 자동으로 올라간다. 도구 실행 결과·첨부·파일 이력은 빠지고 비밀 모양은 가려지지만, '
|
|
4856
|
+
+ `**주고받은 글과 생각(thinking), 도구 호출 인자는 그대로** ${T} 운영진에게 보인다. `
|
|
4857
|
+
+ '끄는 길 — 포털 「폴더」에서 자동 올리기 끄기(서버가 강제), 이 폴더의 .genetics-private 파일 또는 '
|
|
4858
|
+
+ 'GENETICS_NO_AUTO_PUSH=1(이 기계의 클라이언트 약속), 이 대화만 빼려면 no_auto_push. '
|
|
4859
|
+
+ '이미 올라간 것은 꺼도 안 사라진다 — 포털 「폴더」에서 지운다.';
|
|
4860
|
+
}
|
|
4861
|
+
case 'ambiguous':
|
|
4862
|
+
return `genetics: 이 폴더에 확정이 둘 이상 맞아(${(v.names || []).map(q).join('·')}) 어디로 올릴지 안 고른다 — `
|
|
4863
|
+
+ '포털 「폴더」에서 하나를 걷으면 그때부터 자동으로 올라간다.';
|
|
4864
|
+
case 'unbound': {
|
|
4865
|
+
const cands = (v.candidates || []).filter((c) => c && c.tenant)
|
|
4866
|
+
.map((c) => `${q(c.tenant)}(${Number(c.attested_days) || 0}일에 걸쳐, 처음 ${String(c.first_seen_at || '').slice(0, 10)})`);
|
|
4867
|
+
/*
|
|
4868
|
+
* ⚠ **「안 올라간다」가 이 대화에도 참인지 본다**(개인정보 M7). 폴더는 확정 안 됐어도, 전에 사람이 손으로
|
|
4869
|
+
* 올린 대화는 서버가 요청하면 옛 길로 계속 **갱신**된다(§5.5 ②). 그 창에서 「안 올라간다」고 말하면 거짓이다.
|
|
4870
|
+
*/
|
|
4871
|
+
if (v.pushed_before) {
|
|
4872
|
+
return `genetics: 이 폴더(${v.path})는 아직 어느 소속처에도 확정되지 않았다 — 새 대화는 서버에 안 올라간다. `
|
|
4873
|
+
+ `다만 이 대화는 전에 사람이 ${q(v.pushed_before)} 로 올린 적이 있어, 서버가 요청하면 그 사본만 최신으로 갱신된다`
|
|
4874
|
+
+ '(주고받은 글이 계속 쌓인다). 이 대화만 멈추려면 no_auto_push, 이미 올라간 것은 포털 「폴더」에서 지운다.';
|
|
4875
|
+
}
|
|
4876
|
+
return `genetics: 이 폴더(${v.path})는 아직 어느 소속처에도 확정되지 않았다 — 대화는 서버에 안 올라가고, `
|
|
4877
|
+
+ '메일·보유자·「이 세션이 속한 일」도 여기서는 안 돈다. 포털 「폴더」에서 사람이 확정하고 자동 올리기를 켜면 '
|
|
4878
|
+
+ '그 뒤는 자동이다(push_session 은 제안까지 한다).'
|
|
4879
|
+
+ (cands.length ? ` 지금까지 이 자리에서 올라온 곳: ${cands.join('·')} — 기록이지 답이 아니다.` : '');
|
|
4880
|
+
}
|
|
4881
|
+
case 'unsafe':
|
|
4882
|
+
return `genetics: 이 폴더(${v.path})는 집·뿌리·임시 폴더라 소속을 정하지 않는다 — 프로젝트 폴더에서 열면 그때 묻는다.`;
|
|
4883
|
+
case 'conflict':
|
|
4884
|
+
// 담당인 소속처면 그 이름을 «» 로 넣는다. 아니면 이름 없이(§4.1 오라클 금지).
|
|
4885
|
+
return `genetics: 이 대화는 이미 ${v.session_tenant ? `${q(v.session_tenant)} 에` : '다른 곳에'} 올라가 있어 자동으로 올리지 않는다`
|
|
4886
|
+
+ `(이 폴더는 ${q(v.tenant || '')} 로 확정돼 있다). 어디로 갈지는 포털 작업 기록에서 사람이 옮긴다.`;
|
|
4887
|
+
case 'moved':
|
|
4888
|
+
return `genetics: 이 대화는 ${v.moved_from ? `${q(v.moved_from)} 에서 ` : ''}${q(v.moved_to || '')} 로 옮겨졌다 — 색인을 고쳤다.`;
|
|
4889
|
+
case 'offline':
|
|
4890
|
+
return 'genetics: 서버에 닿지 못해 이 폴더의 소속을 모른다 — 소속 없음으로 두고 아무것도 올리지 않는다'
|
|
4891
|
+
+ `(다음 시도 ${Math.max(1, Number(v.retry_min) || 1)}분 뒤).`;
|
|
4892
|
+
/*
|
|
4893
|
+
* ⚠ resolve 가 **잠시** 안 될 때와 **아예 없을** 때를 가른다(호환 M7·M9). 전에는 둘 다 조용해서,
|
|
4894
|
+
* 창 스물다섯 개를 한꺼번에 열면 대부분의 창이 아무 말 없이 「소속 없음」으로 돌았다.
|
|
4895
|
+
*/
|
|
4896
|
+
case 'resolve_busy':
|
|
4897
|
+
return 'genetics: 소속 확인 요청이 잠시 상한에 닿아 이 폴더의 소속을 못 받았다'
|
|
4898
|
+
+ `(다음 시도 ${Math.max(1, Number(v.retry_min) || 1)}분 뒤) — 그동안은 소속 없음으로 두고 아무것도 올리지 않는다. `
|
|
4899
|
+
+ '창을 여러 개 한꺼번에 열면 잠시 이렇게 된다.';
|
|
4900
|
+
case 'no_resolve':
|
|
4901
|
+
return 'genetics: 이 서버에는 폴더 소속 창구가 없어 소속을 확인하지 못한다(서버가 낡았다) — '
|
|
4902
|
+
+ '자동 올리기도, 메일·보유자·「이 세션이 속한 일」도 여기서는 안 돈다. 서버를 올리면 그때부터 돈다.';
|
|
4903
|
+
case 'no_human':
|
|
4904
|
+
return 'genetics: 사람 자격이 없어 폴더 소속을 확인하지 않는다(키는 한 소속처 발행용이다) — '
|
|
4905
|
+
+ 'login 도구로 로그인하면 그때부터 확인한다.';
|
|
4906
|
+
case 'client_old':
|
|
4907
|
+
return `genetics: 이 판(${v.version})은 서버 최소 판(${v.min_client})보다 낡아 폴더 소속·자동 올리기가 안 돈다. `
|
|
4908
|
+
+ 'npx 캐시를 비우고 다시 깔 것: rm -rf ~/.npm/_npx && npx -y genetics-mcp@latest.';
|
|
4909
|
+
default:
|
|
4910
|
+
return '';
|
|
4911
|
+
}
|
|
4912
|
+
}
|
|
4913
|
+
|
|
4914
|
+
/** 지금 이 창의 상태 — 아직 답을 모르면 null. */
|
|
4915
|
+
function tenantNoticeView() {
|
|
4916
|
+
if (!userCred()) return { state: 'no_human' };
|
|
4917
|
+
let real = process.cwd();
|
|
4918
|
+
try { real = fs.realpathSync(real); } catch { /* */ }
|
|
4919
|
+
const v = resolveView.get(real);
|
|
4920
|
+
if (!v) return null;
|
|
4921
|
+
const mine = mySessionId();
|
|
4922
|
+
if (v.state === 'offline') return { state: 'offline', retry_min: Math.ceil((resolveFail.next_at - Date.now()) / 60_000) };
|
|
4923
|
+
if (v.state === 'resolve_busy') return { state: 'resolve_busy', retry_min: Math.ceil((resolveFail.next_at - Date.now()) / 60_000) };
|
|
4924
|
+
if (v.state === 'no_resolve') return { state: 'no_resolve' };
|
|
4925
|
+
if (v.state === 'unsafe' || v.state === 'no_human') return { state: v.state, path: v.fo.path };
|
|
4926
|
+
const it = v.item || {};
|
|
4927
|
+
const e = v.entry || {};
|
|
4928
|
+
const row = indexRead()[mine];
|
|
4929
|
+
const says = resolveSessionSays(it.session); // 그 uuid 에 대한 서버의 사실(N10) — bound 갈래가 읽는다
|
|
4930
|
+
const base = { state: it.state, tenant: e.tenant, path: v.fo.path, confirmed_by: e.confirmed_by,
|
|
4931
|
+
confirmed_at: e.confirmed_at, auto_upload: e.auto_upload };
|
|
4932
|
+
switch (it.state) {
|
|
4933
|
+
case 'bound':
|
|
4934
|
+
return { ...base, private_file: v.private_file, env_off: !autoPushOn(),
|
|
4935
|
+
/*
|
|
4936
|
+
* ★★ **수명이 지난 답으로는 동의 깃발을 다시 세우지 않는다**(개인정보 N10). 이 답은 「그때」의
|
|
4937
|
+
* 사실이고, 사람이 포털에서 이 대화를 가린 일은 **바로 그 사이**에 일어난다. resolve 가 다시
|
|
4938
|
+
* 안 돌아도(서버가 낡아 200 이 아니거나 403 role 이면 `resolveRun()` 은 이 창의 답을 안 갈아
|
|
4939
|
+
* 끼운다) 이 자리는 30분마다 같은 문구를 내고 그때마다 그 폴더의 동의 깃발을 다시 세웠다.
|
|
4940
|
+
* 수명이 지나면 자동 올리기는 이미 멈춰 있으므로(`autoSkipReason()` 의 `stale`) 문구도 멈춘다 —
|
|
4941
|
+
* 「답이 아직 없으면 아무 말도 안 한다」와 같은 태도다.
|
|
4942
|
+
* ⚠ 이 칸은 **서버의 hidden 을 대신하지 못한다** — 서버가 `session.state:"hidden"` 을 낼 때만
|
|
4943
|
+
* 「가렸다」를 말할 수 있다(부록 D-1 · `server_hidden` 아래 줄).
|
|
4944
|
+
*/
|
|
4945
|
+
stale: e.stale === true || Date.now() - Number(v.at || 0) >= RESOLVE_TTL_MS,
|
|
4946
|
+
// 보류는 색인 밖 명부가 정본이다(§5.8·H2) — 색인이 절단돼도 남는다. 404 로 서버에 못 적었으면 그 사실도 말한다.
|
|
4947
|
+
// ★ 서버가 그 uuid 의 살아 있는 행에 보류가 있다고 답하면(`session.hold:true`, N10) 이 기계에 표가 없어도 보류다 —
|
|
4948
|
+
// 다른 사람·다른 기기가 포털에서 건 보류다. 그때는 「이 기계에만 있다」가 거짓이므로 hold_local 을 안 세운다.
|
|
4949
|
+
hold: says.hold || autoHoldsRead().set.has(String(mine || '').toLowerCase()) || row?.auto?.hold === true,
|
|
4950
|
+
hold_local: row?.auto?.hold_local === true && !says.hold && !says.tomb,
|
|
4951
|
+
// 서버가 이 uuid 를 「가렸다·걷었다」(`hidden`) · 「올라가기 전 보류」(`tomb`)라고 답했나(§4.1·부록 D-1) — 안 내는 서버에서는 언제나 거짓이다(N10).
|
|
4952
|
+
server_hidden: says.hidden,
|
|
4953
|
+
server_tomb: says.tomb,
|
|
4954
|
+
// 동의 깃발은 **이 폴더**에만 선다(N8) — 문구가 가리킨 폴더와 올릴 폴더가 다를 수 있다.
|
|
4955
|
+
consent_key: autoConsentKey(e),
|
|
4956
|
+
global_off: resolveGlobalOn === false,
|
|
4957
|
+
client_old: autoClientOld(), version: VERSION, min_client: LAST_MIN_CLIENT };
|
|
4958
|
+
case 'unbound':
|
|
4959
|
+
if (it.reason === 'unsafe_root') return { state: 'unsafe', path: v.fo.path };
|
|
4960
|
+
return { ...base,
|
|
4961
|
+
// 확정은 없어도 **이 대화**는 옛 길로 계속 갱신될 수 있다(§5.5 ②) — 그 사실을 문구가 말한다.
|
|
4962
|
+
pushed_before: (Number(row?.tenant_id) > 0 && !!transcriptOf(mine)) ? String(row.tenant || row.tenant_slug || '') : '',
|
|
4963
|
+
candidates: (Array.isArray(it.candidates) ? it.candidates : [])
|
|
4964
|
+
.map((c) => ({ tenant: c?.tenant?.name, attested_days: c?.attested_days, first_seen_at: c?.first_seen_at })) };
|
|
4965
|
+
case 'ambiguous':
|
|
4966
|
+
return { ...base, names: (Array.isArray(it.bindings) ? it.bindings : []).map((x) => x?.tenant?.name).filter(Boolean) };
|
|
4967
|
+
case 'conflict': {
|
|
4968
|
+
const st = it.session?.tenant;
|
|
4969
|
+
return { ...base, session_tenant: typeof st === 'string' ? st : String(st?.name || '') };
|
|
4970
|
+
}
|
|
4971
|
+
case 'moved': {
|
|
4972
|
+
const m = resolveMovedSaid.get(mine);
|
|
4973
|
+
return m ? { state: 'moved', moved_from: m.from, moved_to: m.to } : null; // 색인을 안 고쳤으면 말하지 않는다
|
|
4974
|
+
}
|
|
4975
|
+
default:
|
|
4976
|
+
return null;
|
|
4977
|
+
}
|
|
4978
|
+
}
|
|
4979
|
+
|
|
4980
|
+
/*
|
|
4981
|
+
* ── 판 낡음 (§5.4·§5.8·§9.4) ─────────────────────────────────────────────
|
|
4982
|
+
* 서버가 모든 /api/* 머리에 싣는 `X-Genetics-Min-Client` 보다 이 판이 낮으면 **한 프로세스에 한 번** stderr 와 도구 꼬리로
|
|
4983
|
+
* 말한다. 비교는 심박이 30분에 한 번(자동 올리기가 426 을 받으면 그 자리에서). 옛 판은 이 머리를 못 읽으므로 서버가
|
|
4984
|
+
* 새 창구의 거절문 몸통에도 같은 안내를 싣는다(§4).
|
|
3184
4985
|
*/
|
|
4986
|
+
function versionLess(a, b) {
|
|
4987
|
+
const p = (s) => String(s || '').split('.').slice(0, 3).map((x) => parseInt(x, 10) || 0);
|
|
4988
|
+
const x = p(a);
|
|
4989
|
+
const y = p(b);
|
|
4990
|
+
for (let i = 0; i < 3; i++) if (x[i] !== y[i]) return x[i] < y[i];
|
|
4991
|
+
return false;
|
|
4992
|
+
}
|
|
4993
|
+
let minClientAt = 0;
|
|
4994
|
+
let minClientSaid = false;
|
|
4995
|
+
let minClientTail = '';
|
|
4996
|
+
function minClientTick(force = false) {
|
|
4997
|
+
if (minClientSaid || !/^\d+\.\d+\.\d+/.test(LAST_MIN_CLIENT)) return;
|
|
4998
|
+
const now = Date.now();
|
|
4999
|
+
if (!force && minClientAt && now - minClientAt < TENANT_NOTICE_MS) return;
|
|
5000
|
+
minClientAt = now;
|
|
5001
|
+
if (!versionLess(VERSION, LAST_MIN_CLIENT)) return;
|
|
5002
|
+
minClientSaid = true;
|
|
5003
|
+
minClientTail = tenantNoticeText({ state: 'client_old', version: VERSION, min_client: LAST_MIN_CLIENT });
|
|
5004
|
+
try { process.stderr.write(`${minClientTail}\n`); } catch { /* 그것마저 막히면 넘어간다 */ }
|
|
5005
|
+
}
|
|
5006
|
+
/** 도구 꼬리에 한 번 — 꺼내면 비운다. */
|
|
5007
|
+
function minClientSays() {
|
|
5008
|
+
const t = minClientTail;
|
|
5009
|
+
minClientTail = '';
|
|
5010
|
+
return t;
|
|
5011
|
+
}
|
|
5012
|
+
|
|
3185
5013
|
function tenantNotice() {
|
|
5014
|
+
// 판 낡음은 폴더 상태가 아니라 이 프로세스의 사실이다 — 한 번만 나가고, 폴더 알림과 한 꼬리에 싣는다.
|
|
5015
|
+
const old = minClientSays();
|
|
5016
|
+
return [old, tenantNoticeFolder()].filter(Boolean).join('\n');
|
|
5017
|
+
}
|
|
5018
|
+
|
|
5019
|
+
function tenantNoticeFolder() {
|
|
3186
5020
|
const mine = mySessionId();
|
|
3187
5021
|
if (!mine) return '';
|
|
5022
|
+
let v;
|
|
3188
5023
|
try {
|
|
3189
|
-
if (
|
|
3190
|
-
|
|
5024
|
+
if (!credential()) return ''; // 자격이 아예 없으면 다른 안내가 나간다
|
|
5025
|
+
v = tenantNoticeView();
|
|
3191
5026
|
} catch { return ''; }
|
|
5027
|
+
/*
|
|
5028
|
+
* ★★ **수명이 지난 답으로는 「올라간다」고 말하지도, 동의 깃발을 세우지도 않는다**(개인정보 N10).
|
|
5029
|
+
* 그 문구는 동의문 그 자체라, 사람이 포털에서 이 대화를 가린 뒤에도 30분마다 되풀이되면 **가려진 대화의
|
|
5030
|
+
* 동의가 날마다 새로 서는** 꼴이 된다. 그 사이 자동 올리기는 이미 `stale` 로 멈춰 있으므로(§5.5 3)
|
|
5031
|
+
* 문구만 참이 아닌 채 남는다. 침묵은 「답이 아직 없을 때」와 같은 대우다 — 되풀이 시계도 안 건드린다.
|
|
5032
|
+
* ⚠ 막는 상태의 문구(전역·사적 파일·환경변수·보류·판 낡음)는 낡아도 여전히 참이라 그대로 나간다.
|
|
5033
|
+
*/
|
|
5034
|
+
if (v?.stale === true && autoNoticeIsConsent({ ...v, stale: false })) return '';
|
|
5035
|
+
const text = tenantNoticeText(v);
|
|
5036
|
+
if (!text) return '';
|
|
5037
|
+
const key = [v.state, v.tenant || '', v.path || '', v.auto_upload ? 1 : 0, v.private_file ? 1 : 0, v.env_off ? 1 : 0, v.hold ? 1 : 0,
|
|
5038
|
+
v.global_off ? 1 : 0, v.client_old ? 1 : 0, v.hold_local ? 1 : 0, v.server_hidden ? 1 : 0, v.server_tomb ? 1 : 0,
|
|
5039
|
+
v.pushed_before || ''].join('|');
|
|
3192
5040
|
const now = Date.now();
|
|
3193
|
-
if (now - tenantSaidAt < TENANT_NOTICE_MS) return '';
|
|
3194
|
-
tenantSaidAt
|
|
3195
|
-
//
|
|
3196
|
-
//
|
|
3197
|
-
|
|
3198
|
-
|
|
3199
|
-
|
|
3200
|
-
|
|
3201
|
-
|
|
3202
|
-
|
|
3203
|
-
|
|
3204
|
-
|
|
3205
|
-
|
|
3206
|
-
|
|
3207
|
-
|
|
3208
|
-
|
|
3209
|
-
|
|
3210
|
-
} catch { /* 알림은 최선일 뿐 — 실패해도 본문은 나간다 */ }
|
|
3211
|
-
return 'genetics: 이 세션은 아직 어느 소속처에도 안 붙어 있다 — 이 폴더에 앞 세션이 없어 '
|
|
3212
|
-
+ '자동 등록이 안 됐다. `push_session` 으로 한 번 정하면 그 뒤는 자동이고, '
|
|
3213
|
-
+ '그래야 메일·보유자·「이 세션이 속한 일」이 이 저장소에서 돈다.' + tail;
|
|
5041
|
+
if (now - (tenantSaidAt.get(key) || 0) < TENANT_NOTICE_MS) return '';
|
|
5042
|
+
tenantSaidAt.set(key, now);
|
|
5043
|
+
// ★ **동의 표시가 나간 순간**만 그 **폴더**의 대화가 올라갈 자격을 얻는다(§5.5·개인정보 M5·N8). 막는 말(전역·판·
|
|
5044
|
+
// 사적 파일·보류·서버 가림·자동 꺼짐)은 동의가 아니다 — 그 상태의 문구는 이 깃발을 안 세운다.
|
|
5045
|
+
if (autoNoticeIsConsent(v) && v.consent_key) autoConsentSaid.add(v.consent_key);
|
|
5046
|
+
return text;
|
|
5047
|
+
}
|
|
5048
|
+
|
|
5049
|
+
/**
|
|
5050
|
+
* 그 상태의 문구가 §5.4 「bound·auto on」(동의 표시) 인가 — 그 한 상태에서만 참이다.
|
|
5051
|
+
*
|
|
5052
|
+
* ⚠ `stale`(수명이 지난 답)도 **동의가 아니다**(개인정보 N10) — 그 답은 「그때」의 사실이고, 사람이
|
|
5053
|
+
* 포털에서 이 대화를 가리는 일은 바로 그 사이에 일어난다. 그 답으로는 자동 올리기도 안 돈다(§5.5 3).
|
|
5054
|
+
*/
|
|
5055
|
+
function autoNoticeIsConsent(v) {
|
|
5056
|
+
return !!v && v.state === 'bound' && v.auto_upload === true
|
|
5057
|
+
&& !v.global_off && !v.private_file && !v.env_off && !v.hold && !v.server_hidden && !v.server_tomb && !v.client_old && !v.stale;
|
|
3214
5058
|
}
|
|
3215
5059
|
|
|
3216
5060
|
function worldDelta() {
|
|
@@ -3326,8 +5170,8 @@ async function brainPull() {
|
|
|
3326
5170
|
if (!r?.truncated) break;
|
|
3327
5171
|
}
|
|
3328
5172
|
// 빈 폴링은 수신이 아니다 — 실제로 온 것이 있을 때만 도장을 찍는다.
|
|
3329
|
-
|
|
3330
|
-
|
|
5173
|
+
// 커서는 소속 칸을 안 건드린다(§5.1 P1-a) — 읽어 둔 `e` 가 그 사이 낡았을 수 있다.
|
|
5174
|
+
indexPatch(mine, { brain_cursor: brainCursor }, fresh.length ? 'rx' : '');
|
|
3331
5175
|
|
|
3332
5176
|
const now = Date.now();
|
|
3333
5177
|
for (const ev of fresh) {
|
|
@@ -3602,7 +5446,7 @@ async function mailPull() {
|
|
|
3602
5446
|
const r = await api('GET', '/api/sessions/' + encodeURIComponent(mine)
|
|
3603
5447
|
+ '?tenant=' + encodeURIComponent(indexTenant) + '&no_body=1');
|
|
3604
5448
|
if (r?.bake) {
|
|
3605
|
-
|
|
5449
|
+
indexPatch(mine, { bake: r.bake }); // 소속 칸은 안 건드린다(§5.1 P1-a)
|
|
3606
5450
|
}
|
|
3607
5451
|
} catch { /* 옛 서버는 이 칸이 없다 — 없으면 상태바가 「모름」으로 둔다 */ }
|
|
3608
5452
|
}
|
|
@@ -3630,8 +5474,7 @@ async function mailPullOne(tenant, mine, indexEntry) {
|
|
|
3630
5474
|
// 색인에는 색인의 소속처 것만 적는다(다른 곳의 커서는 프로세스 안에서만 — 잃어도 ack 가 중복을 막는다).
|
|
3631
5475
|
// 빈 폴링은 수신이 아니다 — 위 백신 커서와 같은 규율.
|
|
3632
5476
|
if (indexEntry?.tenant_id) {
|
|
3633
|
-
|
|
3634
|
-
rows.length ? 'rx' : '');
|
|
5477
|
+
indexPatch(mine, { mail_cursor: cursor }, rows.length ? 'rx' : ''); // 소속 칸은 안 건드린다(§5.1 P1-a)
|
|
3635
5478
|
}
|
|
3636
5479
|
|
|
3637
5480
|
if (rows.length) {
|
|
@@ -3782,6 +5625,34 @@ function requireArgs(name, args) {
|
|
|
3782
5625
|
+ `받는 이름은 이것뿐입니다 — ${all}`);
|
|
3783
5626
|
}
|
|
3784
5627
|
|
|
5628
|
+
/*
|
|
5629
|
+
* ── `works` 한 줄의 「항목」 토막 (DESIGN-genetics-ssot.md §7.7) ───────────────────────────
|
|
5630
|
+
*
|
|
5631
|
+
* 항목에는 **두 출처**가 있다: 그 일을 가리키는 **결정**(`decisions/….md`)과, 공개된 **보고서**의
|
|
5632
|
+
* 열린 항목(`reports/<id>#<uid>` — 건의 미결 · 상대에게 하는 요청). 앞의 셈(`open`·`total`·`done`)은
|
|
5633
|
+
* 둘을 **합친** 수이고, 보고서 토막은 「그중 몇이 어느 보고서에서 오나」를 말한다.
|
|
5634
|
+
*
|
|
5635
|
+
* ★ **방어적으로 그린다** — 서버가 그 칸을 아직 안 보내면 **오늘 내는 글자 그대로** 낸다.
|
|
5636
|
+
* 이 렌더는 S-report-work 의 서버 변경보다 먼저 서고, 두 갈래가 서로를 안 기다린다.
|
|
5637
|
+
* 읽는 칸(정본은 §7.7 의 이 줄이고, 그 모양을 여기 못 박는다):
|
|
5638
|
+
* items.reports = [{ id: <보고서 번호>, case: <열린 건(미결) 수>, ask: <열린 요청 수> }, …]
|
|
5639
|
+
* `case`·`ask` 는 §7.1 `report_open_items()` 의 `kind` 와 **같은 낱말**이다(건=미결 · 요청=ask).
|
|
5640
|
+
* 칸이 없거나·배열이 아니거나·번호가 없는 줄은 **없는 것으로** 친다(조용히 오늘 모양으로 돌아간다).
|
|
5641
|
+
* ⚠ 보고서 항목의 **상태는 보고서가 정본**이라, 이 줄은 완료를 안 센다 — 미결을 비우고 `update_report`
|
|
5642
|
+
* 로 다시 보내면 닫힌다(§7.7 의 409 문구와 같은 규율).
|
|
5643
|
+
*/
|
|
5644
|
+
function worksItemsLine(items) {
|
|
5645
|
+
const it = items && typeof items === 'object' ? items : {};
|
|
5646
|
+
const total = Number(it.total) || 0;
|
|
5647
|
+
if (!total) return '';
|
|
5648
|
+
const head = ` · 항목 ${total}(열림 ${Number(it.open) || 0}${Number(it.done) ? `·완료 ${Number(it.done)}` : ''})`;
|
|
5649
|
+
const rows = (Array.isArray(it.reports) ? it.reports : [])
|
|
5650
|
+
.map((r) => (r && typeof r === 'object' ? r : {}))
|
|
5651
|
+
.filter((r) => Number(r.id) > 0)
|
|
5652
|
+
.map((r) => `보고서 #${Number(r.id)} 미결 ${Number(r.case) || 0}·요청 ${Number(r.ask) || 0}`);
|
|
5653
|
+
return rows.length ? `${head} · 그중 ${rows.join(' · ')}` : head;
|
|
5654
|
+
}
|
|
5655
|
+
|
|
3785
5656
|
async function callTool(name, args = {}) {
|
|
3786
5657
|
requireArgs(name, args);
|
|
3787
5658
|
switch (name) {
|
|
@@ -3844,7 +5715,7 @@ async function callTool(name, args = {}) {
|
|
|
3844
5715
|
* 안 말하면 「왜 이 소속처로 발행됐지」를 사람이 짐작해야 한다.
|
|
3845
5716
|
*/
|
|
3846
5717
|
const alsoKey = keyCred()
|
|
3847
|
-
? '\n(키도 있습니다 —
|
|
5718
|
+
? '\n(키도 있습니다 — **발행·내리기·그림 조회만** 키로 나가고 나머지(공개·교체 포함)는 이 신원으로 갑니다.)'
|
|
3848
5719
|
: '';
|
|
3849
5720
|
const me = await api('GET', '/api/me');
|
|
3850
5721
|
/*
|
|
@@ -3996,8 +5867,18 @@ async function callTool(name, args = {}) {
|
|
|
3996
5867
|
+ (follow.length ? `\n\n${follow.join('\n')}` : '');
|
|
3997
5868
|
}
|
|
3998
5869
|
case 'get_report': {
|
|
3999
|
-
|
|
5870
|
+
if (args.current_text !== undefined && typeof args.current_text !== 'boolean') throw new Error('current_text는 참 또는 거짓이어야 합니다.');
|
|
5871
|
+
if (args.current_text && args.outline) throw new Error('current_text와 outline은 함께 요청할 수 없습니다.');
|
|
5872
|
+
let q = args.current_text ? '?current_text=1' : (args.outline ? '?outline=1' : '');
|
|
5873
|
+
if (args.tenant !== undefined) {
|
|
5874
|
+
if (typeof args.tenant !== 'string' || !args.tenant.trim()) throw new Error('tenant는 소속처 이름이어야 합니다.');
|
|
5875
|
+
q += (q ? '&' : '?') + 'tenant=' + encodeURIComponent(args.tenant);
|
|
5876
|
+
}
|
|
4000
5877
|
const r = await api('GET', `/api/reports/${Number(args.id)}${q}`);
|
|
5878
|
+
if (args.current_text) {
|
|
5879
|
+
if (!r.current_text || typeof r.current_text.text !== 'string') throw new Error('현재 본문을 확인하지 못했습니다.');
|
|
5880
|
+
return JSON.stringify(r.current_text, null, 2);
|
|
5881
|
+
}
|
|
4001
5882
|
notesSeen(Number(args.id), r.open_notes);
|
|
4002
5883
|
if (!args.outline) {
|
|
4003
5884
|
/*
|
|
@@ -4113,7 +5994,7 @@ async function callTool(name, args = {}) {
|
|
|
4113
5994
|
case 'release_report': {
|
|
4114
5995
|
const body = { tenant: args.tenant };
|
|
4115
5996
|
if (args.published_at) body.published_at = args.published_at;
|
|
4116
|
-
const r = await api('POST', `/api/reports/${Number(args.id)}/publish`, body,
|
|
5997
|
+
const r = await api('POST', `/api/reports/${Number(args.id)}/publish`, body, RELEASE);
|
|
4117
5998
|
return (r.changed ? '열었습니다 — 고객이 지금부터 볼 수 있습니다.' : '이미 열려 있습니다.')
|
|
4118
5999
|
+ `\n${r.url}`;
|
|
4119
6000
|
}
|
|
@@ -4137,8 +6018,31 @@ async function callTool(name, args = {}) {
|
|
|
4137
6018
|
case 'update_report': {
|
|
4138
6019
|
const data = await materializeAssets(args.data);
|
|
4139
6020
|
const payload = { ...data, tenant: args.tenant };
|
|
6021
|
+
// 노출 손잡이는 떼고 보낸다(2026-09-14). get_report 가 돌려주는 data 에는 발행 때 보낸 몸통이
|
|
6022
|
+
// 그대로 남아 있어(source_json) `publish`·`published_at` 이 메아리로 섞여 온다 — 그 값이
|
|
6023
|
+
// 서버의 `publish: true` 손잡이를 당기면 초안이 열린다. 여는 동사는 release_report 하나다.
|
|
6024
|
+
delete payload.publish;
|
|
6025
|
+
delete payload.published_at;
|
|
4140
6026
|
if (args.dry_run) payload.dry_run = true;
|
|
4141
|
-
|
|
6027
|
+
/*
|
|
6028
|
+
* (판 9 · 개인정보 ①) 서버는 읽고 굽는 사이 사람이 공개 상태를 바꿨으면 **아무것도 안 쓰고** 409 를 낸다
|
|
6029
|
+
* (`code: report_publish_changed`). 서버 문장은 HTTP 경로로 말하므로(「다시 읽고 보내 주시기 바랍니다」)
|
|
6030
|
+
* 아래 초안 갈래(`r.published === false`)와 같은 규약으로 **이 도구의 동사**를 이어 붙인다 — 안 그러면
|
|
6031
|
+
* 에이전트가 같은 몸통을 그대로 다시 밀어(같은 409) 두 번 헛돈다. 그 몸통은 이미 낡았다.
|
|
6032
|
+
*/
|
|
6033
|
+
let r;
|
|
6034
|
+
try {
|
|
6035
|
+
r = await api('PUT', `/api/reports/${Number(args.id)}`, payload, RELEASE);
|
|
6036
|
+
} catch (e) {
|
|
6037
|
+
if (Number(e?.status) === 409 && String(e?.code || '') === 'report_publish_changed') {
|
|
6038
|
+
// ⚠ **상태 확인까지만 잇는다**(판 9 · 오너가 고른 규약) — 재전송을 권하지 않는다. 지금 몸통은 이미 낡았고,
|
|
6039
|
+
// 무엇을 할지는 지금 상태를 본 **사람**이 정한다(그 사이 누가 내렸는지 올렸는지 도구는 모른다).
|
|
6040
|
+
// ⚠ 서버 문장이 이미 「get_report 로 지금 상태를 확인하세요」라고 말한다 — 여기서 그 말을 **되풀이하지 않고**
|
|
6041
|
+
// 서버가 모르는 것(어느 id 로 부르나)만 보탠다. 행이 사라진 409(`report_gone`)에는 안 붙인다 — 볼 문서가 없다.
|
|
6042
|
+
throw new Error(`${e.message}\n→ get_report(id: ${Number(args.id)})`);
|
|
6043
|
+
}
|
|
6044
|
+
throw e;
|
|
6045
|
+
}
|
|
4142
6046
|
// 고친 직후가 「무엇을 아직 안 고쳤나」를 볼 가장 좋은 순간이다.
|
|
4143
6047
|
if (!r.dry_run) notesSeen(Number(args.id), r.open_notes);
|
|
4144
6048
|
if (r.dry_run) {
|
|
@@ -4146,7 +6050,9 @@ async function callTool(name, args = {}) {
|
|
|
4146
6050
|
}
|
|
4147
6051
|
// 상태를 함께 알린다 — 이 문서를 다음에도 API 로 고칠 수 있는지가 여기서 갈린다.
|
|
4148
6052
|
const st = r.state ? ` [${r.state}]` : '';
|
|
4149
|
-
|
|
6053
|
+
// 서버가 초안이라고 답하면 이 도구의 동사로 다음 할 일을 잇는다(서버 note 는 HTTP 경로로 말한다).
|
|
6054
|
+
const open = r.published === false ? ` 이 도구에서는 release_report(id: ${Number(args.id)}) 가 그 일입니다.` : '';
|
|
6055
|
+
return `교체 완료 — ${r.tenant} · 건 ${r.cases}${st}\n열람 주소: ${r.url}\n${r.note || ''}${open}`
|
|
4150
6056
|
+ pendingText(r) + warnText(r);
|
|
4151
6057
|
}
|
|
4152
6058
|
case 'world': {
|
|
@@ -4202,19 +6108,28 @@ async function callTool(name, args = {}) {
|
|
|
4202
6108
|
const lines = [`${r.tenant} — 열린 일 ${r.open ?? rows.length}개` + ((r.closed ?? 0) > 0 ? ` (지난 일 ${r.closed}개)` : '')];
|
|
4203
6109
|
// 차례를 사람이 정했으면 그 출처를 말한다 — 「…부터 해라」가 아니라 「…이 정한 차례다」(서술형).
|
|
4204
6110
|
if (r.order_from?.title) lines.push(` 차례는 결정 «${r.order_from.title}» 이 정했다 — 위에서부터가 그 차례다.`);
|
|
6111
|
+
let reportSeen = false;
|
|
4205
6112
|
for (const w of rows) {
|
|
6113
|
+
if (Array.isArray(w.items?.reports) && w.items.reports.some((x) => Number(x?.id) > 0)) reportSeen = true;
|
|
4206
6114
|
lines.push(` · «${w.name || w.slug}» (${w.slug}) — ${w.word} · 세션 ${w.sessions}`
|
|
4207
|
-
// 항목 = 그 일을 가리키는
|
|
4208
|
-
// done 은 실행 축 집계(095) — 어느 항목인지는 안 말한다, work+item 으로 따로 확인.
|
|
4209
|
-
+ (w.items
|
|
6115
|
+
// 항목 = 그 일을 가리키는 결정, 그리고 공개된 보고서의 열린 항목(§7.7). 「무엇을 하기로 했나」가 몇 건인지.
|
|
6116
|
+
// 0 이면 안 붙인다. done 은 실행 축 집계(095) — 어느 항목인지는 안 말한다, work+item 으로 따로 확인.
|
|
6117
|
+
+ worksItemsLine(w.items)
|
|
4210
6118
|
+ (w.stale ? ` · 낡음 ${w.stale}일째(AI 관측 — 활동이 멎었다: 사람이 닫거나 다시 살릴 일)` : '')
|
|
4211
6119
|
+ (w.doc ? ` · 요지: get_case(tenant, "${w.doc}")` : ' · 요지 문서 아직 없음'));
|
|
4212
6120
|
}
|
|
4213
|
-
if (!rows.length) lines.push('열린 일이 없다 — 0건은 0건이다. 착수 전에 세우려면 그 과업의 결정을 remember_decision 으로 남기되 **work 에 일의 열쇠를 적어라** —
|
|
6121
|
+
if (!rows.length) lines.push('열린 일이 없다 — 0건은 0건이다. 착수 전에 세우려면 그 과업의 결정을 remember_decision 으로 남기되 **work 에 일의 열쇠를 적어라** — 저장 응답의 work_note 로 「계획」이 선 것을 확인하고 이 도구를 다시 부른다. 표지 없이 tags 만 달면 신호가 쌓일 때까지 AI 가 기다린다. 군체 화면은 열린 일이 있을 때 소속을 확정하는 자리 — 0건이면 고를 일이 없다.');
|
|
4214
6122
|
else lines.push('붙을 일이 보이면 works(tenant, claim: "<slug>") 로 선언한다 — 확정은 사람이 화면에서 한다.'
|
|
4215
6123
|
+ '\n그 일 안의 특정 결정 항목을 쥐었으면 works(tenant, claim: "<slug>", item: "decisions/<page>.md") 로 함께 선언한다.'
|
|
4216
6124
|
+ '\n항목은 그 일의 열쇠·갈래를 단 **결정**이다 — 무엇을 하기로 했는지는 지식의 결정 장에 있고, 새 항목은 remember_decision 으로 남긴다.'
|
|
4217
6125
|
+ '\n항목을 실제로 끝냈으면 works(tenant, work: "<slug>", item: "…", done_when: "…", done_evidence: "…") 로 직접 완료를 표시한다 — 사람이 화면에서 누르길 기다릴 필요 없다.');
|
|
6126
|
+
/*
|
|
6127
|
+
* ★ 보고서에서 온 항목이 섞여 있을 때만 한 줄 더한다(§7.7) — 그 항목의 **상태는 보고서가 정본**이라
|
|
6128
|
+
* 여기서 완료를 찍으면 서버가 409 로 막는다. 그 사실을 먼저 말해 두는 것이 헛걸음을 줄인다.
|
|
6129
|
+
* 보고서 항목이 하나도 없는 소속처의 출력은 **오늘과 한 글자도 다르지 않다**.
|
|
6130
|
+
*/
|
|
6131
|
+
if (reportSeen) lines.push('보고서에서 온 항목(reports/<id>#<uid>)은 여기서 완료로 못 찍는다 — 그 상태는 보고서 내용이 정본이다. '
|
|
6132
|
+
+ '그 건의 미결을 비우고 update_report 로 다시 보내면 닫힌다.');
|
|
4218
6133
|
return lines.join('\n');
|
|
4219
6134
|
}
|
|
4220
6135
|
case 'list_breaks': {
|
|
@@ -4319,7 +6234,7 @@ async function callTool(name, args = {}) {
|
|
|
4319
6234
|
paths: args.paths || [], commands: args.commands || [], errors: args.errors || [],
|
|
4320
6235
|
picks: Array.isArray(args.picks) ? args.picks : [], // 집은 자리(2026-09-03) — 그대로 넘긴다, 모양은 서버가 검사
|
|
4321
6236
|
// ★ **출처는 우리가 채운다 — 모델에게 안 묻는다.** 스키마에 노출하면 지어낸다
|
|
4322
|
-
// (DESIGN-session-as-vcs.md
|
|
6237
|
+
// (DESIGN-session-as-vcs.md §5.1 「출처를 도구 인자로 두지 말 것」). 모르면 안 보내고, 서버는 안 적는다.
|
|
4323
6238
|
...myCitation(),
|
|
4324
6239
|
});
|
|
4325
6240
|
// 밀기 결과도 그대로 옮긴다 — 몇에게 갔고 누가 왜 못 받았는지(구서버는 필드가 없어 침묵).
|
|
@@ -4466,7 +6381,7 @@ async function callTool(name, args = {}) {
|
|
|
4466
6381
|
// **본 것을 적어 둔다.** 이 짝(uuid → 고객사 id)이 없으면 심박을 보낼 수가 없다 —
|
|
4467
6382
|
// 프레임에는 고객사 이름이 아니라 id 가 실린다.
|
|
4468
6383
|
for (const s of sessions) {
|
|
4469
|
-
if (!s.deleted) indexNote(s.uuid, { tenant: r.tenant, tenant_id: s.tenant_id, head_id: s.head_id, head_ms: s.head_ms, ...(r.tenant_slug ? { tenant_slug: r.tenant_slug } : {}) });
|
|
6384
|
+
if (!s.deleted) indexNote(s.uuid, { src: 'list', tenant: r.tenant, tenant_id: s.tenant_id, head_id: s.head_id, head_ms: s.head_ms, ...(r.tenant_slug ? { tenant_slug: r.tenant_slug } : {}) });
|
|
4470
6385
|
}
|
|
4471
6386
|
const p = r.pagination && typeof r.pagination === 'object' ? r.pagination : null;
|
|
4472
6387
|
let count = `세션 ${sessions.length}건`;
|
|
@@ -4496,8 +6411,10 @@ async function callTool(name, args = {}) {
|
|
|
4496
6411
|
+ (follow.length ? `\n\n${follow.join('\n')}` : '');
|
|
4497
6412
|
}
|
|
4498
6413
|
case 'push_session': {
|
|
4499
|
-
const { r, before, parts } = await pushSession(args.tenant, args.uuid, args.title, args.dry_run);
|
|
6414
|
+
const { r, before, parts } = await pushSession(args.tenant, args.uuid, args.title, args.dry_run, 'propose');
|
|
4500
6415
|
const cut = `${before.toLocaleString()}B → ${r.bytes.toLocaleString()}B`;
|
|
6416
|
+
// 폴더 제안의 결과 — 문장의 정본은 서버다(§4.2). 옛 서버는 칸이 없어 침묵한다.
|
|
6417
|
+
const bindNote = typeof r.binding?.note === 'string' && r.binding.note !== '' ? r.binding.note + '\n' : '';
|
|
4501
6418
|
// 조각으로 갔으면 말한다 — 크기 때문에 사람이 할 일이 없다는 것을 결과가 보여 준다.
|
|
4502
6419
|
const how = parts > 1 ? ` · ${parts}조각으로` : '';
|
|
4503
6420
|
if (r.dry_run) {
|
|
@@ -4516,6 +6433,7 @@ async function callTool(name, args = {}) {
|
|
|
4516
6433
|
return `${r.replaced ? '갈아 끼웠습니다' : '올렸습니다'} — ${r.tenant} · ${args.uuid.slice(0, 8)}\n`
|
|
4517
6434
|
+ `${r.lines}줄 · 뺀 레코드 ${r.dropped}(첨부·파일이력) · 가린 자리 ${r.redacted} · ${cut}${how}\n`
|
|
4518
6435
|
+ (queued ? queued + '\n' : '')
|
|
6436
|
+
+ bindNote
|
|
4519
6437
|
+ '다른 기계에서: pull_session 으로 받은 뒤 그 폴더에서 `claude --resume ' + args.uuid + '`';
|
|
4520
6438
|
}
|
|
4521
6439
|
case 'resume_session': {
|
|
@@ -4542,7 +6460,7 @@ async function callTool(name, args = {}) {
|
|
|
4542
6460
|
const q = `?tenant=${encodeURIComponent(args.tenant)}`;
|
|
4543
6461
|
const r = await api('GET',
|
|
4544
6462
|
`/api/sessions/${encodeURIComponent(args.uuid)}${q}&no_body=1&digest=1&digest_chars=${chars}`);
|
|
4545
|
-
indexNote(args.uuid, { tenant: r.tenant || args.tenant, tenant_id: r.tenant_id, head_id: r.head_id, head_ms: r.head_ms, ...(r.tenant_slug ? { tenant_slug: r.tenant_slug } : {}) }, 'rx');
|
|
6463
|
+
indexNote(args.uuid, { src: 'resume', tenant: r.tenant || args.tenant, tenant_id: r.tenant_id, head_id: r.head_id, head_ms: r.head_ms, ...(r.tenant_slug ? { tenant_slug: r.tenant_slug } : {}) }, 'rx');
|
|
4546
6464
|
|
|
4547
6465
|
// 원본도 제자리에 놓는다 — **덮지 않는다**(pull_session 과 같은 이유).
|
|
4548
6466
|
const into = args.into || process.cwd();
|
|
@@ -4553,6 +6471,8 @@ async function callTool(name, args = {}) {
|
|
|
4553
6471
|
placed = `${target} (이미 있어 그대로 두었습니다 — 덮으면 이 기계에서 하던 대화가 사라집니다)`;
|
|
4554
6472
|
} else {
|
|
4555
6473
|
await apiToFile(`/api/sessions/${encodeURIComponent(args.uuid)}${q}&raw=1`, target);
|
|
6474
|
+
// 받아 놓은 사본이다 — 폴더 소속의 증거로 안 쓴다(§5.1). 이미 있던 사본이면 안 바꾼다.
|
|
6475
|
+
indexPatch(args.uuid, { origin: 'pull' });
|
|
4556
6476
|
}
|
|
4557
6477
|
|
|
4558
6478
|
/*
|
|
@@ -4603,7 +6523,7 @@ async function callTool(name, args = {}) {
|
|
|
4603
6523
|
`/api/sessions/${encodeURIComponent(args.uuid)}?tenant=${encodeURIComponent(args.tenant)}&raw=1`,
|
|
4604
6524
|
target);
|
|
4605
6525
|
const m = limMeta(headers);
|
|
4606
|
-
indexNote(args.uuid, { tenant: args.tenant, tenant_id: m.tenant_id, head_id: m.head_id, head_ms: m.head_ms, ...(m.tenant_slug ? { tenant_slug: m.tenant_slug } : {}) }, 'rx');
|
|
6526
|
+
indexNote(args.uuid, { src: 'pull', origin: 'pull', tenant: args.tenant, tenant_id: m.tenant_id, head_id: m.head_id, head_ms: m.head_ms, ...(m.tenant_slug ? { tenant_slug: m.tenant_slug } : {}) }, 'rx');
|
|
4607
6527
|
// 헤더를 안 주는 옛 서버면 **지어내지 않는다** — 받은 바이트만 말한다.
|
|
4608
6528
|
const size = `${((m.bytes ?? bytes) / 1024).toFixed(0)}KB`;
|
|
4609
6529
|
return `받았습니다 — ${args.uuid.slice(0, 8)} · ${m.lines === null ? '' : `${m.lines}줄 · `}${size}\n`
|
|
@@ -4657,8 +6577,8 @@ async function callTool(name, args = {}) {
|
|
|
4657
6577
|
// ⚠ 서버가 tenant_id 를 아직 안 주는 판(재배포 전)이어도 **받기는 이름만으로 된다** —
|
|
4658
6578
|
// GET /api/mail 은 tenant 이름으로 묻는다. 색인(indexNote)만 id 가 있어야 적힌다.
|
|
4659
6579
|
mailTenants.set(args.tenant, r?.tenant_id || 0); // 이 소속처에서도 받는다 — 합집합, 폴백 아님
|
|
4660
|
-
if (r?.tenant_id && !indexRead()[mine]?.tenant) indexNote(mine, { tenant: args.tenant, tenant_id: r.tenant_id, ...(r.tenant_slug ? { tenant_slug: r.tenant_slug } : {}) });
|
|
4661
|
-
return heldNote + `«${r.to}» 앞으로 보냈다 — 실 ${r.thread} · ${r.turn}
|
|
6580
|
+
if (r?.tenant_id && !indexRead()[mine]?.tenant) indexNote(mine, { src: 'mail', tenant: args.tenant, tenant_id: r.tenant_id, ...(r.tenant_slug ? { tenant_slug: r.tenant_slug } : {}) });
|
|
6581
|
+
return heldNote + `«${r.to}» 앞으로 보냈다 — 실 ${r.thread} · 이 실의 ${r.turn}번째 통.\n`
|
|
4662
6582
|
+ `받는 쪽이 보게 될 글자는 이것이다:\n${r.preview}\n`
|
|
4663
6583
|
+ (r.says ? `${r.says}\n` : '')
|
|
4664
6584
|
+ '답이 오면 다른 도구를 부를 때 응답 꼬리에 저절로 실린다 — 따로 확인할 필요 없다.';
|
|
@@ -4681,6 +6601,39 @@ async function callTool(name, args = {}) {
|
|
|
4681
6601
|
'다시 깔려면 `install_cell`.'].join('\n');
|
|
4682
6602
|
}
|
|
4683
6603
|
|
|
6604
|
+
case 'no_auto_push': {
|
|
6605
|
+
/*
|
|
6606
|
+
* 대화 하나를 자동 올리기에서 뺀다(§5.8). **이 기계의 약속 먼저**(색인 auto.hold) — 서버에 못 닿아도 이 클라이언트는
|
|
6607
|
+
* 안 올린다. 사람 자격이 있으면 서버가 강제하는 쪽(관문 7 의 auto_hold)도 세운다. 서버에 그 행이 없으면(404) 아직
|
|
6608
|
+
* 올라간 것이 없어 적을 자리도 없다 — 그것은 실패가 아니다.
|
|
6609
|
+
*/
|
|
6610
|
+
const uuid = String(args.uuid || mySessionId() || '').toLowerCase();
|
|
6611
|
+
if (!AUTO_UUID_RE.test(uuid)) {
|
|
6612
|
+
throw new Error(args.uuid ? `세션 id 모양이 아닙니다: ${args.uuid}`
|
|
6613
|
+
: '이 세션의 번호를 모른다 — uuid 를 주세요(CLAUDE_CODE_SESSION_ID 도, Cursor 활성 대화도 없는 환경이다).');
|
|
6614
|
+
}
|
|
6615
|
+
/*
|
|
6616
|
+
* ★ **보류는 색인 밖 명부가 정본이다**(호환 H2). `sessions.json` 은 옛 판이 잠금 없이 절단해서 쓰는 파일이라
|
|
6617
|
+
* 거기에만 적은 보류는 옛 판 창 하나가 지나가면 사라진다. 그래서 `auto-holds.json` 을 먼저 적고,
|
|
6618
|
+
* 그것이 실패하면 **도구가 실패한다** — 「표시했다」고 답해 놓고 안 지키는 것이 가장 나쁘다.
|
|
6619
|
+
* 색인에도 적는 것은 옛 자리를 읽는 코드와 화면을 위한 사본이다.
|
|
6620
|
+
*/
|
|
6621
|
+
if (!autoHoldSet(uuid)) throw new Error(`보류 명부에 적지 못했습니다: ${HOLDS_FILE}`);
|
|
6622
|
+
const missed = [];
|
|
6623
|
+
let onServer = false;
|
|
6624
|
+
if (userCred()) {
|
|
6625
|
+
for (const t of autoHoldTenants(uuid)) {
|
|
6626
|
+
try { await api('POST', `/api/sessions/${encodeURIComponent(uuid)}/hold`, { tenant: t }); onServer = true; }
|
|
6627
|
+
catch (e) { if (Number(e?.status) !== 404) missed.push(`${t}: ${e.message}`); }
|
|
6628
|
+
}
|
|
6629
|
+
}
|
|
6630
|
+
// 서버에 적히지 못한 보류는 **이 기계에만** 있다(§5.4 hold·로컬) — 그 사실을 알림이 말한다.
|
|
6631
|
+
autoHoldSet(uuid, !onServer);
|
|
6632
|
+
indexPatch(uuid, { auto: { hold: true, hold_local: !onServer } });
|
|
6633
|
+
return '이 대화는 자동으로 안 올라간다. 이미 올라간 부분은 포털 「폴더」에서 지운다.'
|
|
6634
|
+
+ (missed.length ? `\n(서버에는 표시하지 못했다 — ${missed.join(' · ')}. 이 기계의 클라이언트만 지킨다.)` : '');
|
|
6635
|
+
}
|
|
6636
|
+
|
|
4684
6637
|
default:
|
|
4685
6638
|
throw new Error(`알 수 없는 도구: ${name}`);
|
|
4686
6639
|
}
|
|
@@ -4921,13 +6874,49 @@ function copyFileAtomic(src, dst) {
|
|
|
4921
6874
|
fs.renameSync(tmp, dst);
|
|
4922
6875
|
}
|
|
4923
6876
|
|
|
4924
|
-
|
|
4925
|
-
|
|
6877
|
+
/*
|
|
6878
|
+
* 【선언】 **(호환 L17) MCP 가 세포를 붙잡아 쓰면(`GENETICS_CELL_BIN`) 훅도 그 바이너리를 부르게 한다.**
|
|
6879
|
+
* 세포의 `hook_bin()` 순서는 `GCELL_HOOK_BIN` → 정본 자리(`~/.config/genetics/bin/gcell`, 있으면) → 도는 것이다
|
|
6880
|
+
* (`cell/src/main.rs` · §9.5 — 그 순서는 안 바꾼다). 그런데 붙잡은 갈래(`cellInstall()` 의 `forced`)는 **정본 자리를
|
|
6881
|
+
* 아예 안 갈아 끼우므로**, 그 기계에 옛 정본이 남아 있으면 훅은 영영 그 옛 것을 부르고 MCP 는 붙잡은 것을 부른다 —
|
|
6882
|
+
* 세포가 두 벌이다(세포가 `hook_bin_split_notice` 로 알리기만 하던 자리). 그래서 붙잡은 갈래에서만 `GCELL_HOOK_BIN` 을
|
|
6883
|
+
* **함께** 넘긴다. ⚠ 사람이 이미 `GCELL_HOOK_BIN` 을 줬으면 그 값이 이긴다(안 덮는다). ⚠ 경로는 절대 경로로 —
|
|
6884
|
+
* 훅은 다른 자리에서 불린다. 이 줄을 `false` 로 두면 옛 동작(정본 자리 우선)으로 돌아간다.
|
|
6885
|
+
*/
|
|
6886
|
+
const CELL_PIN_MOVES_HOOKS = true;
|
|
6887
|
+
|
|
6888
|
+
/*
|
|
6889
|
+
* 【선언】 **(판 8 — 호환 F4) 훅을 옮기는 붙잡음은 환경변수의 것뿐이다 — `api.env` 의 줄은 안 옮긴다.**
|
|
6890
|
+
* `gcell install-hooks` 는 **언제나** `api.env` 에 `GENETICS_CELL_BIN=<지금 도는 바이너리>` 를 적는다(`cell/src/main.rs`,
|
|
6891
|
+
* 「MCP 가 찾을 수 있게」). 그 파일을 `cfg` 로 합치므로, 워크트리 빌드로 `install-hooks --apply` 를 **한 번** 돌린 기계는
|
|
6892
|
+
* 부팅마다 붙잡은 갈래로 가고, L17 은 그 빌드를 Claude·Cursor·Codex 훅에 `GCELL_HOOK_BIN` 으로 박았다 — 빌드를 지우면
|
|
6893
|
+
* **모든 훅이 없는 파일을 가리켰다.** 그래서 핀은 `process.env` 로 붙잡았을 때만 넘긴다(`cellPinSource() === 'env'`).
|
|
6894
|
+
* `api.env` 에서만 온 붙잡음은 판 6 처럼 세포의 `hook_bin()` 순서(정본 자리 우선)를 따른다.
|
|
6895
|
+
* 【선언】 **붙잡은 세포가 없으면 훅을 정본 자리(`CELL_PATH`)로 다시 건다** — 전에는 「지정한 세포가 없다」만 말하고 훅을
|
|
6896
|
+
* 부서진 채로 뒀다. 정본 자리에 세포가 있으면 그것으로 `install-hooks` 를 부르고(핀 없이 — 그 세포가 `api.env` 의 줄도
|
|
6897
|
+
* 자기 경로로 고쳐 적는다), 없으면 아래 정본 갈래가 이 판의 세포를 깔고 그것으로 건다. 이 프로세스의 `cellCheck()` 는
|
|
6898
|
+
* 여전히 「지정한 세포가 없다」다 — `cfg` 는 뜰 때 한 번 읽는다(다음 부팅부터 고친 줄을 읽는다).
|
|
6899
|
+
*/
|
|
6900
|
+
/** `GENETICS_CELL_BIN` 이 어디서 왔나 — `'env'`(process.env) · `'file'`(api.env 에서만) · `''`(없음). **순수 함수.** */
|
|
6901
|
+
function cellPinSource(procEnv = process.env, merged = cfg) {
|
|
6902
|
+
if (String(procEnv?.GENETICS_CELL_BIN || '') !== '') return 'env';
|
|
6903
|
+
if (String(merged?.GENETICS_CELL_BIN || '') !== '') return 'file';
|
|
6904
|
+
return '';
|
|
6905
|
+
}
|
|
6906
|
+
|
|
6907
|
+
/** 붙잡은 세포로 훅을 걸 때의 환경(호환 L17). 순수 — `env` 를 안 고치고 새 객체를 낸다. */
|
|
6908
|
+
function cellHookEnv(bin, env = process.env, pinned = CELL_PIN_MOVES_HOOKS) {
|
|
6909
|
+
if (!pinned || !bin || String(env.GCELL_HOOK_BIN || '') !== '') return env;
|
|
6910
|
+
return { ...env, GCELL_HOOK_BIN: path.resolve(String(bin)) };
|
|
6911
|
+
}
|
|
6912
|
+
|
|
6913
|
+
/** `install-hooks --apply` 를 지정한 바이너리로 돌린다. 조용해야 할 때는 빈 문자열. `pinned` 면 훅도 그 바이너리로(L17). */
|
|
6914
|
+
function cellInstallHooksVia(bin, ifRepo, pinned = false) {
|
|
4926
6915
|
// `ifRepo` 는 **자동으로 부를 때만** 준다 — 사람이 `install_cell` 을 부른 자리는 자기가
|
|
4927
6916
|
// 정한 곳이지만, 부팅마다 도는 걸음은 git 저장소가 아닌 폴더에 설정을 만들면 안 된다.
|
|
4928
6917
|
const a = ['install-hooks', '--apply'];
|
|
4929
6918
|
if (ifRepo) a.push('--if-repo');
|
|
4930
|
-
const r = spawnSync(bin, a, { encoding: 'utf8', timeout: 10_000 });
|
|
6919
|
+
const r = spawnSync(bin, a, { encoding: 'utf8', timeout: 10_000, env: pinned ? cellHookEnv(bin) : process.env });
|
|
4931
6920
|
// 세포의 `HOOKS_UNCHANGED` 와 **글자 그대로** 맞춘다 — smoke 가 두 파일을 대조한다.
|
|
4932
6921
|
const so = r.stdout || '';
|
|
4933
6922
|
const quiet = r.status === 0 && (so.includes('바뀐 것 없다.') || so.includes('git 저장소가 아니다'));
|
|
@@ -4945,16 +6934,28 @@ function cellInstall({ hooks = true, ifRepo = false } = {}) {
|
|
|
4945
6934
|
// 동작이 어긋났다 — 지정해 놔도 아래 공식판 지문 대조·`copyFileSync` 를 그대로 타
|
|
4946
6935
|
// 매 부팅마다 지정한 파일을 덮어썼다(2026-09-02 사고의 원인). 지정이 있으면 지문
|
|
4947
6936
|
// 대조·CELL_PATH 자체를 안 본다 — 훅만 **그 바이너리 기준**으로 맞춘다.
|
|
4948
|
-
|
|
4949
|
-
|
|
4950
|
-
return { ok: false, out: [`지정한 세포가 없다: ${forced} — GENETICS_CELL_BIN 을 확인하라.`] };
|
|
4951
|
-
}
|
|
6937
|
+
const pinFrom = cellPinSource();
|
|
6938
|
+
if (forced && fs.existsSync(forced)) {
|
|
4952
6939
|
if (hooks) {
|
|
4953
|
-
|
|
6940
|
+
// 훅도 붙잡은 그 바이너리로 — **환경변수로 붙잡았을 때만**(호환 L17 · 판 8 호환 F4 · `CELL_PIN_MOVES_HOOKS`)
|
|
6941
|
+
const msg = cellInstallHooksVia(forced, ifRepo, pinFrom === 'env');
|
|
4954
6942
|
if (msg) out.push(msg);
|
|
4955
6943
|
}
|
|
4956
6944
|
return { ok: true, out };
|
|
4957
6945
|
}
|
|
6946
|
+
if (forced) {
|
|
6947
|
+
// (판 8 — 호환 F4) 없는 핀을 가리키는 훅을 남기지 않는다 — 정본 자리로 다시 건다(위 【선언】).
|
|
6948
|
+
out.push(`지정한 세포가 없다: ${forced}(${pinFrom === 'env' ? '환경변수' : 'api.env'}) — GENETICS_CELL_BIN 을 확인하라.`
|
|
6949
|
+
+ (hooks ? ` 훅은 정본 자리(${CELL_PATH})로 다시 건다.` : ''));
|
|
6950
|
+
if (fs.existsSync(CELL_PATH)) {
|
|
6951
|
+
if (hooks) {
|
|
6952
|
+
const msg = cellInstallHooksVia(CELL_PATH, ifRepo);
|
|
6953
|
+
if (msg) out.push(msg);
|
|
6954
|
+
}
|
|
6955
|
+
return { ok: false, out };
|
|
6956
|
+
}
|
|
6957
|
+
// 정본 자리도 비었으면 아래 정본 갈래가 이 판의 세포를 깔고 그것으로 훅을 건다.
|
|
6958
|
+
}
|
|
4958
6959
|
|
|
4959
6960
|
const t = cellTarget();
|
|
4960
6961
|
if (!t) {
|
|
@@ -5052,6 +7053,62 @@ function ensureCellOnBoot() {
|
|
|
5052
7053
|
}
|
|
5053
7054
|
}
|
|
5054
7055
|
|
|
7056
|
+
/*
|
|
7057
|
+
* ── 스킬 사본의 도장 — **내용 해시** (DESIGN-genetics-ssot.md §9.3) ─────────────────────────────
|
|
7058
|
+
*
|
|
7059
|
+
* 전에는 판 번호(`.installed-version`)만 봤다. 그러면 ① 판을 안 올리고 고친 스킬은 영영 안 깔리고 ② 사람이 손댄
|
|
7060
|
+
* 사본은 판이 바뀌는 날 말없이 지워졌다. 이제 원본(src)과 깐 사본(dst)의 내용 해시를 도장에 둘 다 적는다:
|
|
7061
|
+
* src·dst 가 도장과 같다 → 건너뛴다
|
|
7062
|
+
* src 만 다르다 → 새 판이다 — 깔고 도장을 다시 쓴다
|
|
7063
|
+
* dst 가 도장과 다르다 → 사람이 손댄 사본이다 — `~/.claude/skills/.genetics-backup/<이름>-<ISO>/` 로 옮겨 두고 깐다
|
|
7064
|
+
* 도장이 하나도 없다 → 남의 손 사본으로 보고 옮겨 두고 깐다(판 도장만 있으면 옛 판이 깐 것 — 그냥 깐다)
|
|
7065
|
+
* 심볼릭 링크 → 건너뛴다(사람의 개발 자리) · GENETICS_SKILL_LINK=1 이면 복사 대신 링크
|
|
7066
|
+
* `.installed-version` 도 계속 쓴다(옛 판이 「우리가 깐 표시」로 그 파일을 본다).
|
|
7067
|
+
* ⚠ 옛 판이 한 개라도 살아 있으면 갱신은 보장되지 않는다 — 옛 판은 판이 다르면 무조건 지우고 자기 것을 깐다.
|
|
7068
|
+
* 저장소 뿌리의 `skills-lock.json` 은 외부(git) 스킬의 **출처 원장**이고 이 도장은 **이 패키지가 깐 사본의 표시**다 — 겹치지 않는다.
|
|
7069
|
+
*/
|
|
7070
|
+
const SKILL_HASH_SKIP = new Set(['.installed-version', '.installed-hash', '.DS_Store']);
|
|
7071
|
+
|
|
7072
|
+
/** 디렉터리 내용 해시 — 정렬된 상대경로마다 "<경로>\0<파일 sha256>\n" 을 이은 것의 sha256(__pycache__·도장·.DS_Store 제외). */
|
|
7073
|
+
function skillHash(dir) {
|
|
7074
|
+
const rows = [];
|
|
7075
|
+
const walk = (d, rel) => {
|
|
7076
|
+
for (const e of fs.readdirSync(d, { withFileTypes: true })) {
|
|
7077
|
+
const r = rel ? `${rel}/${e.name}` : e.name;
|
|
7078
|
+
const full = path.join(d, e.name);
|
|
7079
|
+
if (e.isDirectory()) { if (e.name !== '__pycache__') walk(full, r); continue; }
|
|
7080
|
+
if (SKILL_HASH_SKIP.has(e.name)) continue;
|
|
7081
|
+
rows.push([r, crypto.createHash('sha256').update(fs.readFileSync(full)).digest('hex')]);
|
|
7082
|
+
}
|
|
7083
|
+
};
|
|
7084
|
+
walk(dir, '');
|
|
7085
|
+
rows.sort((x, y) => Buffer.compare(Buffer.from(x[0]), Buffer.from(y[0])));
|
|
7086
|
+
return crypto.createHash('sha256').update(rows.map(([p, h]) => `${p}\0${h}\n`).join('')).digest('hex');
|
|
7087
|
+
}
|
|
7088
|
+
|
|
7089
|
+
/** `.installed-hash` 를 읽는다 — 모양이 아니면 null(= 도장 없음). */
|
|
7090
|
+
function skillStampRead(dir) {
|
|
7091
|
+
try {
|
|
7092
|
+
const t = fs.readFileSync(path.join(dir, '.installed-hash'), 'utf8');
|
|
7093
|
+
const src = t.match(/^src=sha256:([0-9a-f]{64})$/m)?.[1] || '';
|
|
7094
|
+
const dst = t.match(/^dst=sha256:([0-9a-f]{64})$/m)?.[1] || '';
|
|
7095
|
+
return src && dst ? { src, dst } : null;
|
|
7096
|
+
} catch { return null; }
|
|
7097
|
+
}
|
|
7098
|
+
|
|
7099
|
+
/**
|
|
7100
|
+
* 그 자리에 무엇을 할까 — 'skip' | 'install' | 'backup'(옮겨 두고 깐다). 순수 함수(시험이 표로 잰다).
|
|
7101
|
+
* src 와 dst 가 **둘 다** 도장과 다르면(새 판 + 손댄 사본) 옮겨 둔다 — 새 판이라는 이유로 사람의 손을 말없이 지우지 않는다.
|
|
7102
|
+
*/
|
|
7103
|
+
function skillPlan({ exists, link = false, stamp = null, hasVersion = false, src = '', dst = '', linkMode = false }) {
|
|
7104
|
+
if (!exists) return 'install';
|
|
7105
|
+
if (link) return 'skip';
|
|
7106
|
+
let plan;
|
|
7107
|
+
if (stamp) plan = stamp.dst !== dst ? 'backup' : (stamp.src !== src ? 'install' : 'skip');
|
|
7108
|
+
else plan = hasVersion ? 'install' : 'backup';
|
|
7109
|
+
return linkMode && plan === 'skip' ? 'install' : plan;
|
|
7110
|
+
}
|
|
7111
|
+
|
|
5055
7112
|
function ensureSkillOnBoot() {
|
|
5056
7113
|
if (process.env.GENETICS_NO_SKILL) return; // 자동 배치를 원치 않는 사람의 탈출구
|
|
5057
7114
|
try {
|
|
@@ -5074,19 +7131,73 @@ function ensureSkillOnBoot() {
|
|
|
5074
7131
|
process.stderr.write(`genetics: 옛 스킬 자리를 치웠다(${old}).\n`);
|
|
5075
7132
|
} catch { /* 없으면 할 일 없다 */ }
|
|
5076
7133
|
}
|
|
7134
|
+
const linkMode = process.env.GENETICS_SKILL_LINK === '1'; // 복사 대신 링크(§9.3) — 레포에서 고치며 쓰는 개발 기계
|
|
7135
|
+
/*
|
|
7136
|
+
* ★ **잠금 안에서 정하고 잠금 안에서 깐다**(호환 검토 L11). 창 스물다섯 개가 한꺼번에 뜨면 전에는 각자
|
|
7137
|
+
* 「무엇을 할까」를 정한 뒤 아무 보호 없이 rename·rm·cp 를 했다 — 한 창이 방금 깐 새 사본을 옆 창이
|
|
7138
|
+
* 「사람이 손댄 것」으로 읽어 `.genetics-backup` 으로 옮기거나, 복사 도중에 지워 반쪽 스킬이 남았다.
|
|
7139
|
+
* 자리마다 잠금 하나(`.genetics-skills.lock`, §5.2 알고리즘)를 쓰고, 깔 때는 **임시 자리에 채운 뒤
|
|
7140
|
+
* rename** 한다 — 그래야 남이 보는 순간에는 언제나 온전한 한 벌이다.
|
|
7141
|
+
*/
|
|
7142
|
+
fs.mkdirSync(dst, { recursive: true });
|
|
7143
|
+
const h = fileLock(path.join(dst, '.genetics-skills'));
|
|
7144
|
+
if (!h) return; // 옆 창이 지금 깔고 있다 — 이번 부팅은 넘어간다
|
|
5077
7145
|
let changed = 0;
|
|
5078
|
-
|
|
7146
|
+
try {
|
|
7147
|
+
sweepLeftovers(path.join(dst, '.genetics-skills'));
|
|
7148
|
+
// 죽은 창이 남긴 반쪽 임시 자리를 걷는다(묵은 것만).
|
|
7149
|
+
for (const n of fs.readdirSync(dst)) {
|
|
7150
|
+
if (!n.startsWith('.genetics-tmp-')) continue;
|
|
7151
|
+
const f = path.join(dst, n);
|
|
7152
|
+
try { if (Date.now() - fs.lstatSync(f).mtimeMs > LOCK_STALE_MS) fs.rmSync(f, { recursive: true, force: true }); } catch { /* 남이 걷었다 */ }
|
|
7153
|
+
}
|
|
7154
|
+
for (const name of fs.readdirSync(src)) {
|
|
7155
|
+
const from = path.join(src, name);
|
|
5079
7156
|
const to = path.join(dst, name);
|
|
5080
|
-
|
|
5081
|
-
|
|
5082
|
-
|
|
5083
|
-
|
|
5084
|
-
|
|
5085
|
-
|
|
5086
|
-
|
|
5087
|
-
|
|
5088
|
-
|
|
5089
|
-
|
|
7157
|
+
// 한 스킬이 못 펴져도 나머지는 편다 — 사람 손 사본에 못 읽는 파일이 섞여 해시가 못 서는 일이 있다.
|
|
7158
|
+
try {
|
|
7159
|
+
if (!fs.statSync(from).isDirectory()) continue;
|
|
7160
|
+
let st = null;
|
|
7161
|
+
try { st = fs.lstatSync(to); } catch { /* 없으면 처음이다 */ }
|
|
7162
|
+
const link = !!st?.isSymbolicLink();
|
|
7163
|
+
const own = !!st && !link;
|
|
7164
|
+
const srcHash = skillHash(from);
|
|
7165
|
+
const plan = skillPlan({
|
|
7166
|
+
exists: !!st, link, linkMode, src: srcHash,
|
|
7167
|
+
dst: own ? skillHash(to) : '',
|
|
7168
|
+
stamp: own ? skillStampRead(to) : null,
|
|
7169
|
+
hasVersion: own && fs.existsSync(path.join(to, '.installed-version')),
|
|
7170
|
+
});
|
|
7171
|
+
if (plan === 'skip') continue;
|
|
7172
|
+
let moved = '';
|
|
7173
|
+
if (plan === 'backup') {
|
|
7174
|
+
moved = path.join(dst, '.genetics-backup', `${name}-${isoStamp()}`);
|
|
7175
|
+
fs.mkdirSync(path.dirname(moved), { recursive: true });
|
|
7176
|
+
fs.renameSync(to, moved);
|
|
7177
|
+
}
|
|
7178
|
+
const stage = path.join(dst, `.genetics-tmp-${name}-${process.pid}-${crypto.randomBytes(4).toString('hex')}`);
|
|
7179
|
+
try {
|
|
7180
|
+
if (linkMode) {
|
|
7181
|
+
fs.symlinkSync(from, stage);
|
|
7182
|
+
} else {
|
|
7183
|
+
fs.cpSync(from, stage, { recursive: true });
|
|
7184
|
+
fs.writeFileSync(path.join(stage, '.installed-version'), VERSION + '\n'); // 옛 판의 「우리가 깐 표시」 판정이 본다
|
|
7185
|
+
fs.writeFileSync(path.join(stage, '.installed-hash'), `src=sha256:${srcHash}\ndst=sha256:${skillHash(stage)}\n`);
|
|
7186
|
+
}
|
|
7187
|
+
fs.rmSync(to, { recursive: true, force: true });
|
|
7188
|
+
fs.renameSync(stage, to);
|
|
7189
|
+
} catch (e) {
|
|
7190
|
+
try { fs.rmSync(stage, { recursive: true, force: true }); } catch { /* */ }
|
|
7191
|
+
throw e;
|
|
7192
|
+
}
|
|
7193
|
+
if (moved) process.stderr.write(`genetics: 손댄 스킬 사본(${name})을 ${moved} 로 옮겨 두고 새로 깔았다.\n`);
|
|
7194
|
+
changed += 1;
|
|
7195
|
+
} catch (e) {
|
|
7196
|
+
process.stderr.write(`genetics: 스킬(${name})을 펴지 못했다 — ${e.message} (서버는 계속 돈다)\n`);
|
|
7197
|
+
}
|
|
7198
|
+
}
|
|
7199
|
+
} finally {
|
|
7200
|
+
fileUnlock(h);
|
|
5090
7201
|
}
|
|
5091
7202
|
if (changed) {
|
|
5092
7203
|
// **stdout 은 프로토콜 전용이다.** 여기에 한 글자라도 쓰면 클라이언트의 JSON 파싱이 깨진다.
|
|
@@ -5127,4 +7238,21 @@ startMailPoll(); // 메일 폴은 심박의 조기 반환에 매이지 않는
|
|
|
5127
7238
|
|
|
5128
7239
|
// 시험용 — 서버 동작에는 쓰이지 않는다. materializeAssets 만 네트워크를 쓴다.
|
|
5129
7240
|
export { encodeAsset, imageDims, imageHostAllowed, materializeAssets, warnText, IMAGE_HOST_ALLOW, HOST_ALLOW,
|
|
5130
|
-
scrubTranscript, splitAtLines, PUSH_PART_MAX
|
|
7241
|
+
scrubTranscript, splitAtLines, PUSH_PART_MAX,
|
|
7242
|
+
// DESIGN-genetics-ssot.md §3·§5 — 색인·잠금·폴더 identity·알림 문구의 순수/파일 함수(시험이 걷는다)
|
|
7243
|
+
jsonFileUpdate, jsonCorruptAside, credWrite, pendingWrite, pendingClear, indexRead, indexNote, indexPatch, indexTenant,
|
|
7244
|
+
folderGitIdentity, folderPathIdentity, folderUnsafeRoot, folderSubpathCovers, folderMask, folderOf, folderForPush, privateFileFor,
|
|
7245
|
+
resolveRequestBody, resolveCacheWrite, tenantNoticeText, RESOLVE_FILE, INDEX_FILE,
|
|
7246
|
+
// §5.5·§5.8·§9.3 — 자동 올리기 거르개·순서·백오프, 판 비교, 스킬 도장(시험이 걷는다)
|
|
7247
|
+
autoSkipReason, autoOrder, autoBackoffMs, AUTO_BODY_MAX, versionLess, skillHash, skillPlan,
|
|
7248
|
+
// §5.5·§5.8 — 동의 시각(D3)·색인 밖 보류 명부(H2)·동의 문구 판정·동의의 폴더 열쇠(N8)
|
|
7249
|
+
autoFloorMs, autoContext, autoHoldsRead, autoHoldSet, autoNoticeIsConsent, autoConsentKey, autoPrivateAnyCwd,
|
|
7250
|
+
// §5.5 7 — 자라면 다시 후보(호환 C2) · 사람이 뺀 대화의 붙박이 표(개인정보 NEW-4) · §7.7 works 항목 토막
|
|
7251
|
+
autoTooOldRefresh, AUTO_STICKY_409, AUTO_CLEAR_ON_PUSH, worksItemsLine,
|
|
7252
|
+
// 라운드 5 — too_old mtime 표시(호환 N8) · 막음 표는 그 소속처만 푼다(N9) · resolve 의 uuid 상태(개인정보 N10) · 훅 핀(L17)
|
|
7253
|
+
autoTooOldMark, autoBlockClearedBy, autoClearOnPush, resolveSessionSays, autoBlockFromResolve, cellHookEnv, CELL_PIN_MOVES_HOOKS,
|
|
7254
|
+
// 판 8 — 다른 소속처의 막음 표를 안 덮는다(적합성 F7) · 훅 핀은 환경변수의 붙잡음만, 없는 핀은 정본으로(호환 F4)
|
|
7255
|
+
autoBlockSetVerdict, autoBlockPut, cellPinSource,
|
|
7256
|
+
// 2026-09-15 — 「서버가 아는 행인가」는 소속 칸이 아니라 서버를 거친 표식으로 가른다(첫 업로드가 영영 안 서던 자리)
|
|
7257
|
+
autoRowOnServer,
|
|
7258
|
+
HOLDS_FILE, UPDATE_BUDGET_MS };
|