genetics-mcp 2.22.0 → 2.24.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 +5 -5
- package/bin/x86_64-apple-darwin/gcell +0 -0
- package/bin/x86_64-pc-windows-gnu/gcell.exe +0 -0
- package/bin/x86_64-unknown-linux-musl/gcell +0 -0
- package/genetics.mjs +2224 -238
- package/package.json +3 -2
- package/skills/gazette-report/SKILL.md +11 -10
- package/skills/gazette-report/scripts/tone.mjs +17 -5
- package/skills/gazette-report/scripts/tone.test.mjs +15 -2
- package/skills/gazette-report/template/skeleton.html +10 -10
- package/skills/genetics-define/SKILL.md +2 -0
- package/skills/genetics-report/SKILL.md +93 -87
- package/skills/genetics-report/assets/example/cases-briefing.json +1 -1
- package/skills/genetics-report/assets/example/cases.json +3 -3
- package/skills/genetics-report/assets/example/report-briefing-designs.json +26 -26
- package/skills/genetics-report/assets/example/report-briefing.json +3 -3
- package/skills/genetics-report/assets/example/report.json +11 -11
- package/skills/genetics-report/assets/mcp-call.mjs +109 -0
- package/skills/genetics-report/assets/patch.py +196 -0
- package/skills/genetics-report/assets/render.py +172 -0
- package/skills/genetics-report/assets/xlsx-plate.py +203 -0
- package/skills/genetics-report/references/content-pipeline.md +26 -26
- package/skills/genetics-report/references/edition-review.workflow.js +70 -15
- package/skills/genetics-report/references/history.md +76 -2
- package/skills/genetics-report/references/lim-portal-format.md +101 -45
- package/skills/genetics-report/references/linear-read-test.workflow.js +50 -14
- package/skills/genetics-report/references/process.md +47 -43
- package/skills/genetics-report/references/reader-review.workflow.js +105 -28
- package/skills/genetics-report/references/visual-first.md +29 -12
package/genetics.mjs
CHANGED
|
@@ -182,11 +182,14 @@ const lockSleepCell = new Int32Array(new SharedArrayBuffer(4));
|
|
|
182
182
|
|
|
183
183
|
function isoStamp() { return new Date().toISOString().replace(/[:.]/g, '-'); }
|
|
184
184
|
|
|
185
|
-
/**
|
|
186
|
-
|
|
185
|
+
/**
|
|
186
|
+
* 잠금 파일이 죽은 자의 것인가 — 같은 호스트의 죽은 pid 이거나 `staleMs`(기본 30초) 넘게 묵었다. 그때 그 내용을, 아니면 null.
|
|
187
|
+
* `staleMs` 는 입구 임대(`LEASE_QUIET_MS`)가 쓴다 — 긴 PUT 을 쥔 산 주인의 임대를 30초 만에 걷지 않게(§3.2 · C7).
|
|
188
|
+
*/
|
|
189
|
+
function lockStaleText(lock, staleMs = LOCK_STALE_MS) {
|
|
187
190
|
let st, text;
|
|
188
191
|
try { st = fs.statSync(lock); text = fs.readFileSync(lock, 'utf8'); } catch { return null; }
|
|
189
|
-
if (Date.now() - st.mtimeMs >
|
|
192
|
+
if (Date.now() - st.mtimeMs > staleMs) return text;
|
|
190
193
|
const [pid, host] = text.split('\n');
|
|
191
194
|
if (host === os.hostname() && /^[1-9][0-9]*$/.test(pid || '')) {
|
|
192
195
|
try { process.kill(Number(pid), 0); } catch (e) { if (e?.code === 'ESRCH') return text; }
|
|
@@ -225,7 +228,8 @@ function sweepLeftovers(file) {
|
|
|
225
228
|
const now = Date.now();
|
|
226
229
|
try {
|
|
227
230
|
for (const n of fs.readdirSync(dir)) {
|
|
228
|
-
|
|
231
|
+
// `<file>.lock.stale-` 은 기본 잠금 자리, `<file>.stale-` 은 잠금 파일을 직접 준 자리(입구 임대 `push/<uuid>.lease`).
|
|
232
|
+
if (!n.startsWith(`${base}.tmp-`) && !n.startsWith(`${base}.lock.stale-`) && !n.startsWith(`${base}.stale-`)) continue;
|
|
229
233
|
const f = path.join(dir, n);
|
|
230
234
|
try { if (now - fs.statSync(f).mtimeMs > LOCK_STALE_MS) fs.rmSync(f, { force: true }); } catch { /* 남이 지웠다 */ }
|
|
231
235
|
}
|
|
@@ -233,12 +237,18 @@ function sweepLeftovers(file) {
|
|
|
233
237
|
}
|
|
234
238
|
|
|
235
239
|
/**
|
|
236
|
-
* @param {number} [deadline] epoch ms — 이 시각을 넘기면 더 안 기다린다(0 이면
|
|
240
|
+
* @param {number} [deadline] epoch ms — 이 시각을 넘기면 더 안 기다린다(0 이면 LOCK_TRIES 만큼, 호환 N9).
|
|
237
241
|
* 기다림은 `Atomics.wait` 이라 **이 고리가 곧 MCP 의 stdio 고리다** — 한 번의 쓰기가 몇 초씩 잡으면 심박도 도구 응답도 멎는다.
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
+
* `Date.now()` 를 주면 **한 번만** 잡아 본다(묵은 잠금을 걷는 한 번은 더 탄다) — 입구 임대의 비차단 모양(§3.2 · C7).
|
|
243
|
+
* @param {{stale?: number, lock?: string}} [o] `stale` 묵음 한도(기본 30초) · `lock` 잠금 파일 자리(기본 `<file>.lock`)
|
|
244
|
+
* — 입구 임대는 `push/<uuid>.lease` 를 그대로 잠금 파일로 쓴다(새 원시를 안 만든다, race S2).
|
|
245
|
+
*/
|
|
246
|
+
function fileLock(file, deadline = 0, o = {}) {
|
|
247
|
+
const lock = o.lock || `${file}.lock`;
|
|
248
|
+
const staleMs = Number(o.stale) > 0 ? Number(o.stale) : LOCK_STALE_MS;
|
|
249
|
+
// 토큰 끝의 난수 — 같은 프로세스·같은 ms 에 잡는 둘(사람 push 와 자동 바퀴, C7)이 서로의 잠금을 제 것으로 읽지 않게.
|
|
250
|
+
// 앞 세 줄(pid·호스트·ms)은 그대로라 `lockStaleText` 와 옛 판이 읽는 모양이 안 바뀐다.
|
|
251
|
+
const token = `${process.pid}\n${os.hostname()}\n${Date.now()}\n${crypto.randomBytes(4).toString('hex')}`;
|
|
242
252
|
let broke = false;
|
|
243
253
|
// deadline 0 = 첫 시도(jsonFileUpdate) — §5.2 대로 **상한 없이** 기다린다(묵은 잠금은 30초에 걷힌다). 2초(LOCK_TRIES)
|
|
244
254
|
// 에서 포기하면 그 쓰기는 `nolock` 으로 되풀이 없이 사라진다 — 창 열둘이 한 파일을 두드리는 관문에서 599/600(2026-09-16 실측).
|
|
@@ -252,7 +262,7 @@ function fileLock(file, deadline = 0) {
|
|
|
252
262
|
if (e?.code !== 'EEXIST') return null;
|
|
253
263
|
}
|
|
254
264
|
if (!broke) {
|
|
255
|
-
const stale = lockStaleText(lock);
|
|
265
|
+
const stale = lockStaleText(lock, staleMs);
|
|
256
266
|
if (stale !== null) {
|
|
257
267
|
broke = true; // 한 번만 걷고 다시 잡는다
|
|
258
268
|
lockBreakStale(lock, stale);
|
|
@@ -799,6 +809,61 @@ function encodeShot(buf) {
|
|
|
799
809
|
+ '여러 장을 한 판에 몰아 넣으면 어차피 문서에서 글자가 안 읽힙니다.');
|
|
800
810
|
}
|
|
801
811
|
|
|
812
|
+
/**
|
|
813
|
+
* 영상 썸네일 판(스프라이트) — 열람 셸의 시크바가 끌 때 「그 자리 그림」을 보이려면 프레임이 있어야 하는데,
|
|
814
|
+
* 문서 CSP(default-src 'none')·sandbox 아래에서는 셸이 매체를 직접 열어 프레임을 뽑을 수 없다(2026-09-23).
|
|
815
|
+
* 그래서 **발행 때 이 기계에서** ffmpeg 로 `step` 초마다 한 장씩 뽑아 한 판(10열 × 140px)으로 붙이고 cwebp 로
|
|
816
|
+
* 굳혀(encodeShot — 장당 상한 ASSET_MAX_ONE 그대로) 자산의 `thumbs` 칸에 data: 로 싣는다. 렌더러가
|
|
817
|
+
* `<video data-thumbs…>` 로 내고 셸(assets/reader-video.js)이 **읽기만** 해서 자기 층에 그린다 — 발행물 바이트의
|
|
818
|
+
* 일부이지 실행 코드가 아니다(문서 CSP img-src data: 안). ffmpeg/ffprobe 가 없으면 **판 없이 간다**(힌트 한 줄) —
|
|
819
|
+
* 영상 자체는 그대로 올라가고 시크바는 그림 없이 선다. 장당 상한을 넘으면 간격을 두 배로 한 번 더 시도한다.
|
|
820
|
+
*/
|
|
821
|
+
const THUMB_W = 140, THUMB_COLS = 10;
|
|
822
|
+
function videoThumbs(fpath, stepSec = 1) {
|
|
823
|
+
const probe = spawnSync('ffprobe', ['-v', 'error', '-select_streams', 'v:0',
|
|
824
|
+
'-show_entries', 'stream=width,height:format=duration', '-of', 'json', fpath], { encoding: 'utf8' });
|
|
825
|
+
if (probe.error && probe.error.code === 'ENOENT') {
|
|
826
|
+
ASSET_HINTS.push(`영상 「${path.basename(fpath)}」: ffprobe/ffmpeg 가 없어 썸네일 판 없이 올립니다(brew install ffmpeg).`);
|
|
827
|
+
return null;
|
|
828
|
+
}
|
|
829
|
+
let meta = null;
|
|
830
|
+
try { meta = JSON.parse(String(probe.stdout || '')); } catch (e) { meta = null; }
|
|
831
|
+
const dur = parseFloat(meta && meta.format && meta.format.duration);
|
|
832
|
+
const sw = Number(meta && meta.streams && meta.streams[0] && meta.streams[0].width);
|
|
833
|
+
const sh = Number(meta && meta.streams && meta.streams[0] && meta.streams[0].height);
|
|
834
|
+
if (!(dur > 0) || !(sw > 0) || !(sh > 0)) return null;
|
|
835
|
+
// 판의 세로가 긴 변 상한(ASSET_LONG_EDGE)을 넘으면 encodeShot 이 줄여 버려 칸 좌표가 어긋난다 — 가로는 10열 × 140 = 1400
|
|
836
|
+
// 으로 딱 상한이고, 세로는 **간격을 늘려** 줄 수를 맞춘다(한 칸 높이 ≈ 140 × 세로/가로, 짝수). 실제 치수는 뽑은 뒤 다시 잰다.
|
|
837
|
+
const frameH = Math.max(2, 2 * Math.round(THUMB_W * sh / sw / 2));
|
|
838
|
+
const fits = (step) => Math.ceil(Math.max(1, Math.ceil(dur / step)) / THUMB_COLS) * frameH <= ASSET_LONG_EDGE;
|
|
839
|
+
for (const mul of [1, 2, 4, 8, 16, 32]) {
|
|
840
|
+
const step = stepSec * mul;
|
|
841
|
+
if (!fits(step)) continue;
|
|
842
|
+
const n = Math.max(1, Math.ceil(dur / step));
|
|
843
|
+
const rows = Math.ceil(n / THUMB_COLS);
|
|
844
|
+
const r = spawnSync('ffmpeg', ['-v', 'error', '-i', fpath,
|
|
845
|
+
'-vf', `fps=1/${step},scale=${THUMB_W}:-2,tile=${THUMB_COLS}x${rows}`,
|
|
846
|
+
'-frames:v', '1', '-f', 'image2pipe', '-vcodec', 'png', '-'], { maxBuffer: IMAGE_MAX_DOWNLOAD });
|
|
847
|
+
if (r.error && r.error.code === 'ENOENT') {
|
|
848
|
+
ASSET_HINTS.push(`영상 「${path.basename(fpath)}」: ffmpeg 가 없어 썸네일 판 없이 올립니다(brew install ffmpeg).`);
|
|
849
|
+
return null;
|
|
850
|
+
}
|
|
851
|
+
if (r.status !== 0 || !r.stdout || !r.stdout.length) {
|
|
852
|
+
ASSET_HINTS.push(`영상 「${path.basename(fpath)}」: 썸네일 판을 못 만들어 없이 올립니다 — ${String(r.stderr).slice(0, 120)}`);
|
|
853
|
+
return null;
|
|
854
|
+
}
|
|
855
|
+
const d = imageDims(r.stdout);
|
|
856
|
+
if (!d || d.w > ASSET_LONG_EDGE || d.h > ASSET_LONG_EDGE || d.h % rows !== 0) continue; // 추정이 빗나갔다 — 간격을 늘려 다시
|
|
857
|
+
let enc;
|
|
858
|
+
try { enc = encodeShot(r.stdout); } catch (e) { continue; } // 장당 상한 초과 — 간격을 늘려 한 번 더
|
|
859
|
+
const h = d.h / rows;
|
|
860
|
+
ASSET_HINTS.push(`영상 「${path.basename(fpath)}」 썸네일 판 ${n}장(${step}초 간격) ${(enc.length / 1024).toFixed(0)}KB.`);
|
|
861
|
+
return { data: 'data:image/webp;base64,' + enc.toString('base64'), n, cols: THUMB_COLS, w: THUMB_W, h, step };
|
|
862
|
+
}
|
|
863
|
+
ASSET_HINTS.push(`영상 「${path.basename(fpath)}」: 썸네일 판이 장당 상한을 넘어 없이 올립니다(영상이 깁니다).`);
|
|
864
|
+
return null;
|
|
865
|
+
}
|
|
866
|
+
|
|
802
867
|
function cwebpMissing() {
|
|
803
868
|
return new Error('cwebp 가 없어 자산을 계약 규격(WebP)으로 굳힐 수 없습니다.\n'
|
|
804
869
|
+ `libwebp 를 설치하세요(${installHint('webp')}). `
|
|
@@ -855,12 +920,88 @@ function encodeAsset(buf, role, provider) {
|
|
|
855
920
|
* 운영 문서가 1400x934 그림을 1400x1050 이라 선언해 브라우저가 세로로 12.4% 늘여
|
|
856
921
|
* 그리고 있었다(2026-08-16 실측). 선언을 안 받으면 그 결함이 구조적으로 안 생긴다.
|
|
857
922
|
*/
|
|
858
|
-
|
|
923
|
+
/** 영상 한 편의 상한 — 서버 R2_VIDEO_MAX_BYTES 와 **같은 값**이다(갈리면 올리다 막힌다). */
|
|
924
|
+
const VIDEO_MAX_BYTES = 200_000_000;
|
|
925
|
+
|
|
926
|
+
/**
|
|
927
|
+
* 로컬 영상 파일 하나. 확장자를 안 믿고 **매직바이트**로 판정한다(서버도 같은 것을 다시 잰다).
|
|
928
|
+
* ISO BMFF 는 [4바이트 크기][ftyp] 라 4바이트째부터 본다.
|
|
929
|
+
*/
|
|
930
|
+
function readLocalVideo(p) {
|
|
931
|
+
if (/^[a-z][a-z0-9+.-]*:/i.test(p)) throw new Error(`file 에는 주소가 아니라 파일 경로를 줍니다: ${p}`);
|
|
932
|
+
if (!path.isAbsolute(p)) throw new Error(`file 은 절대경로여야 합니다: ${p}`);
|
|
933
|
+
let st;
|
|
934
|
+
try { st = fs.statSync(p); } catch { throw new Error(`file 을 찾을 수 없습니다: ${p}`); }
|
|
935
|
+
if (!st.isFile()) throw new Error(`file 이 일반 파일이 아닙니다: ${p}`);
|
|
936
|
+
if (st.size > VIDEO_MAX_BYTES) {
|
|
937
|
+
throw new Error(`영상이 ${st.size.toLocaleString()}B 로 상한 ${VIDEO_MAX_BYTES.toLocaleString()}B 를 넘습니다: ${p}\n`
|
|
938
|
+
+ '길이를 줄이거나 화질을 낮춰 다시 인코딩하세요(ffmpeg -crf 를 올리면 작아집니다).');
|
|
939
|
+
}
|
|
940
|
+
const buf = fs.readFileSync(p);
|
|
941
|
+
if (buf.toString('ascii', 4, 8) !== 'ftyp') {
|
|
942
|
+
throw new Error(`file 이 mp4(ISO BMFF)가 아닙니다(매직바이트 기준): ${p}`);
|
|
943
|
+
}
|
|
944
|
+
return buf;
|
|
945
|
+
}
|
|
946
|
+
|
|
947
|
+
/**
|
|
948
|
+
* 영상을 저장소에 올리고 **참조 번호**를 받는다.
|
|
949
|
+
*
|
|
950
|
+
* 바이트는 이 기계에서 저장소로 **직접** 간다 — 포털을 거치지 않는다(포털은 메모리가 좁고
|
|
951
|
+
* Range 를 모른다). 포털이 하는 일은 「올릴 자리 하나를 원장에 세우고 서명해 주는 것」뿐이다.
|
|
952
|
+
* `headers` 는 **그대로** 실어야 한다 — 그 값이 서명에 들어가 있어서, 하나라도 다르면 저장소가 거절한다.
|
|
953
|
+
*/
|
|
954
|
+
async function uploadVideo(tenant, buf, filePath) {
|
|
955
|
+
if (!tenant) {
|
|
956
|
+
throw new Error('영상을 올리려면 tenant 가 필요합니다 — publish_report·update_report 의 tenant 를 씁니다.');
|
|
957
|
+
}
|
|
958
|
+
const sha256 = crypto.createHash('sha256').update(buf).digest('hex');
|
|
959
|
+
const pre = await api('POST', '/api/media/presign', { tenant, bytes: buf.length, sha256 });
|
|
960
|
+
const res = await fetch(pre.url, { method: 'PUT', body: buf, headers: { ...(pre.headers || {}) } });
|
|
961
|
+
if (!res.ok) {
|
|
962
|
+
const why = (await res.text().catch(() => '')).slice(0, 200);
|
|
963
|
+
throw new Error(`영상을 저장소에 올리지 못했습니다(${res.status}): ${filePath}\n${why}\n`
|
|
964
|
+
+ '설정 화면의 「연결 시험」이 통과하는지 먼저 확인하세요.');
|
|
965
|
+
}
|
|
966
|
+
ASSET_HINTS.push(`영상 「${path.basename(filePath)}」 ${(buf.length / 1048576).toFixed(1)}MB 를 올렸습니다(참조 ${pre.ref}).`);
|
|
967
|
+
return pre.ref;
|
|
968
|
+
}
|
|
969
|
+
|
|
970
|
+
async function materializeAssets(data, tenant) {
|
|
859
971
|
ASSET_HINTS = [];
|
|
860
972
|
if (!data || !Array.isArray(data.assets) || !data.assets.length) return data;
|
|
861
973
|
const out = [];
|
|
862
974
|
for (const a of data.assets) {
|
|
863
975
|
const { data: dropped, file: fpath, ...meta } = a ?? {};
|
|
976
|
+
/*
|
|
977
|
+
* 영상(138) — 바이트를 문서에 싣지 않는다. `file` 에 로컬 mp4 경로를 주면 여기서 올리고
|
|
978
|
+
* **참조 번호만** 문서에 남긴다. 이미 `ref` 가 있으면 올린 것이므로 그대로 둔다
|
|
979
|
+
* (되읽은 판을 고쳐 되돌려 보내는 왕복에서 같은 영상을 두 번 올리지 않는다).
|
|
980
|
+
*/
|
|
981
|
+
if (String(a?.role ?? '') === 'video' || String(a?.provider ?? '') === 'r2') {
|
|
982
|
+
const { data: _vd, thumbs: given, ...rest } = meta;
|
|
983
|
+
/* 썸네일 판 — 실물(data:image/…)이면 그대로, `null` 이면 뺀 것(서버 report_carry_assets 와 같은 뜻).
|
|
984
|
+
* 되읽기 표식('(생략 — NB)')은 실물이 아니다 — 판이 있는 것으로 치면 표식이 그대로 발행돼 거절된다. */
|
|
985
|
+
const optOut = given === null;
|
|
986
|
+
const real = !!(given && typeof given.data === 'string' && given.data.startsWith('data:image/'));
|
|
987
|
+
const slot = (t) => (optOut ? { thumbs: null } : (t && typeof t === 'object' ? { thumbs: t } : {}));
|
|
988
|
+
if (Number(rest.ref) > 0) {
|
|
989
|
+
// 이미 올린 영상 — 판이 실물이 아니면 file 에서 새로 굽는다(영상은 다시 안 올린다). file 도 없으면 받은
|
|
990
|
+
// 표식을 그대로 보낸다 — 서버가 같은 영상(ref)의 저장된 판을 잇는다.
|
|
991
|
+
const t = (optOut || real) ? given : ((fpath ? videoThumbs(String(fpath)) : null) || given);
|
|
992
|
+
out.push({ ...rest, role: 'video', provider: 'r2', ...slot(t) });
|
|
993
|
+
continue;
|
|
994
|
+
}
|
|
995
|
+
if (!fpath) {
|
|
996
|
+
throw new Error(`영상 「${rest.id ?? '(id 없음)'}」 에 file(로컬 mp4 경로)도 ref 도 없습니다.\n`
|
|
997
|
+
+ 'file 에 절대경로를 주면 올리고 번호를 받아 싣습니다.');
|
|
998
|
+
}
|
|
999
|
+
const ref = await uploadVideo(tenant, readLocalVideo(String(fpath)), String(fpath));
|
|
1000
|
+
// 새로 올린 영상 — 표식이 가리키던 옛 판은 다른 장면이라 버리고 이 파일에서 굽는다.
|
|
1001
|
+
const t = (optOut || real) ? given : videoThumbs(String(fpath));
|
|
1002
|
+
out.push({ ...rest, role: 'video', provider: 'r2', ref, mime: 'video/mp4', ...slot(t) });
|
|
1003
|
+
continue;
|
|
1004
|
+
}
|
|
864
1005
|
if (!a?.source && !fpath) {
|
|
865
1006
|
// 둘 다 없으면 예전 경로 그대로 — data 를 그대로 통과시킨다(판 A 하위호환).
|
|
866
1007
|
// get_report 가 준 표식('(생략 — NB)')과 bytes 칸의 처리(2026-08-27, 운영 #34):
|
|
@@ -957,9 +1098,31 @@ function warnOnce(key, msg) {
|
|
|
957
1098
|
try { process.stderr.write(`genetics: ${msg}\n`); } catch { /* 그것마저 막히면 넘어간다 */ }
|
|
958
1099
|
}
|
|
959
1100
|
|
|
1101
|
+
/**
|
|
1102
|
+
* 【선언】 요청 하나의 시간 상한 — **바탕 120초**(DESIGN-session-delta-sync §2.10 · race S1 · C7). 시험은 120초를 못 기다려
|
|
1103
|
+
* `GENETICS_API_TIMEOUT_MS` 로 갈아 끼운다 — 하한 1초(`GENETICS_OBSERVE_MS` 와 같은 규율: 실수로 0 이 들어가 모든 요청이
|
|
1104
|
+
* 즉시 끊기지 않게).
|
|
1105
|
+
*/
|
|
1106
|
+
const API_TIMEOUT_MS = Math.max(1000, Number(cfg.GENETICS_API_TIMEOUT_MS) || 120_000);
|
|
1107
|
+
/**
|
|
1108
|
+
* 【선언】 업링크의 **바닥 속도** 32KB/s(=256kbit/s). 상한을 벽시계 하나로 두면 **큰 봉투가 영영 못 올라간다** —
|
|
1109
|
+
* 조각 하나가 6MiB 라 120초는 52KB/s(=419kbit/s) 이상을 요구했고, 그보다 느린 링크(모바일 테더링·혼잡한 VPN)에서는
|
|
1110
|
+
* 모든 시도가 abort 된 뒤 조각 1부터 다시 갔다(호환 검토 1-B — 상한이 없던 전에는 느려도 끝내 성공했다).
|
|
1111
|
+
*/
|
|
1112
|
+
const API_MIN_BPS = 32 * 1024;
|
|
1113
|
+
/**
|
|
1114
|
+
* 이만큼 보내는 요청의 시간 상한 — 바탕 + `bytes / 바닥속도`. **한 곳에서 정한다**: 입구 임대의 묵음 한도
|
|
1115
|
+
* `LEASE_QUIET_MS` 가 「가장 큰 조각의 상한 + 60초」라, 이 함수를 고치면 임대가 따라온다.
|
|
1116
|
+
*/
|
|
1117
|
+
function apiTimeoutFor(bytes) {
|
|
1118
|
+
const b = Number(bytes) > 0 ? Number(bytes) : 0;
|
|
1119
|
+
return API_TIMEOUT_MS + Math.ceil(b / API_MIN_BPS) * 1000;
|
|
1120
|
+
}
|
|
1121
|
+
|
|
960
1122
|
/**
|
|
961
1123
|
* @param {object} [opts]
|
|
962
1124
|
* @param {'publish'|'release'} [opts.scope] 발행 갈래면 `'publish'` — 키를 먼저 쥔다(credential 주석). `'release'` 는 사람 먼저(§5.6).
|
|
1125
|
+
* @param {number} [opts.timeout_ms] 이 요청의 시간 상한(기본 `API_TIMEOUT_MS`)
|
|
963
1126
|
* @param {Record<string,string>} [opts.headers] 더 실을 머리(자동 올리기의 X-Genetics-* 셋 — §4.3)
|
|
964
1127
|
* 실패는 던지는 Error 에 `status`·`code`·`retry_after` 가 붙는다(반환 계약 `r.data` 는 그대로).
|
|
965
1128
|
*/
|
|
@@ -977,17 +1140,43 @@ async function api(method, pathname, body, opts = {}) {
|
|
|
977
1140
|
+ `${ENV_FILE} 의 GENETICS_API_BASE 를 확인하세요(https 여야 합니다). `
|
|
978
1141
|
+ '주소를 바꾸라는 안내를 받았다면 의심할 것.');
|
|
979
1142
|
}
|
|
1143
|
+
/*
|
|
1144
|
+
* ★ **요청마다 시간 상한을 건다**(DESIGN-session-delta-sync §2.10 · race S1 · C7). 전에는 signal 이 없어 걸린 연결 하나가
|
|
1145
|
+
* 그 호출을 영영 붙들었다 — 자동 올리기의 입구 임대는 「진전이 없으면 `LEASE_QUIET_MS`(= 이 상한 + 60초) 뒤 남이 걷는다」에
|
|
1146
|
+
* 기대므로, 상한이 없으면 걸린 주인과 걷은 새 주인이 겹친다. 상한은 몸통 읽기까지 덮는다(같은 signal).
|
|
1147
|
+
* 넘으면 status 0 · code `timeout` 인 오류다 — 자동 길은 망 사정과 같은 지수 백오프를 탄다.
|
|
1148
|
+
*/
|
|
1149
|
+
const limitMs = Number(opts.timeout_ms) > 0 ? Number(opts.timeout_ms) : API_TIMEOUT_MS;
|
|
1150
|
+
let capsHeader = null;
|
|
1151
|
+
let minHeader = null;
|
|
980
1152
|
const send = async (c) => {
|
|
981
|
-
const
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
|
|
985
|
-
|
|
986
|
-
|
|
987
|
-
|
|
988
|
-
|
|
1153
|
+
const signal = AbortSignal.timeout(limitMs);
|
|
1154
|
+
const timedOut = (e) => (e?.name === 'TimeoutError' || (e?.name === 'AbortError' && signal.aborted)
|
|
1155
|
+
? Object.assign(new Error(`서버가 ${Math.round(limitMs / 1000)}초 안에 답하지 않았습니다(${method} ${pathname}).`), { status: 0, code: 'timeout' })
|
|
1156
|
+
: e);
|
|
1157
|
+
let res, text;
|
|
1158
|
+
try {
|
|
1159
|
+
res = await fetch(BASE + pathname, {
|
|
1160
|
+
method,
|
|
1161
|
+
// opts.headers 는 자동 올리기의 머리 셋(§4.3)이 쓴다 — 자격·형식 머리는 덮지 못한다.
|
|
1162
|
+
headers: { ...(opts.headers || {}), Authorization: `Bearer ${c.token}`, 'Content-Type': 'application/json' },
|
|
1163
|
+
body: body === undefined ? undefined : JSON.stringify(body),
|
|
1164
|
+
signal,
|
|
1165
|
+
});
|
|
1166
|
+
LAST_MIN_CLIENT = res.headers.get('x-genetics-min-client') || LAST_MIN_CLIENT; // §5.8 — 반환 계약은 그대로
|
|
1167
|
+
capsHeader = res.headers.get('x-genetics-session-caps'); // §6.1 — 덧붙임 능력 광고(C8)
|
|
1168
|
+
minHeader = res.headers.get('x-genetics-min-client'); // 「우리 PHP 가 답했다」의 표(아래 capsNote)
|
|
1169
|
+
text = await res.text();
|
|
1170
|
+
} catch (e) { throw timedOut(e); }
|
|
989
1171
|
let data;
|
|
990
|
-
|
|
1172
|
+
// `not_json` — 봉투가 `post_max_size` 를 넘으면 PHP 가 본문을 버리고 200·`text/html` 을 낸다. 덧붙임은 그 표로 조각을 더 쪼갠다(C8).
|
|
1173
|
+
try { data = JSON.parse(text); } catch { throw Object.assign(new Error(`응답을 해석하지 못했습니다(HTTP ${res.status}): ${text.slice(0, 300)}`), { status: res.status, not_json: true }); }
|
|
1174
|
+
/*
|
|
1175
|
+
* ★ 능력 광고는 **JSON 으로 읽힌 응답에서만** 잰다(§6.1 · C8). 봉투가 `post_max_size` 를 넘으면 PHP 가 본문을 버리고
|
|
1176
|
+
* `text/html` + 경고 덩어리를 200 으로 내는데 머리가 하나도 안 실린다(dev 실측) — 그 갈래는 위 `JSON.parse` 에서
|
|
1177
|
+
* 먼저 죽으므로 여기 안 온다. 「광고가 없으니 옛 서버」를 그 응답으로 적으면 조용히 덧붙임이 꺼진다.
|
|
1178
|
+
*/
|
|
1179
|
+
capsNote(pathname, res.status, capsHeader, data, !!minHeader);
|
|
991
1180
|
return { ok: res.ok && data.ok !== false, status: res.status, data };
|
|
992
1181
|
};
|
|
993
1182
|
|
|
@@ -1021,9 +1210,18 @@ async function api(method, pathname, body, opts = {}) {
|
|
|
1021
1210
|
// 자동 올리기의 백오프(§5.5 7)가 상태·code·retry_after 로 갈린다 — 메시지는 그대로, 칸만 붙인다.
|
|
1022
1211
|
// `auto_upload_at` 은 409 `too_old`(관문 11)가 **서버의 동의 시각**을 알려 주는 칸이다 — 그것이 곧 클라이언트가
|
|
1023
1212
|
// 못 받은 하한이라, 받아 두면 같은 거절을 24시간마다 되풀이하지 않는다(개인정보 N3).
|
|
1213
|
+
// `coords` — 409 `behind`·`base_moved` 가 싣는 서버 좌표(C3·C4). 따라잡기(C8)가 읽는다.
|
|
1214
|
+
// `why` — 덧붙임의 409 `no_base`·`rebake`·`base_lost` 가 가르는 칸(C4). §5.1 의 다음 동작이 이것으로 갈린다.
|
|
1215
|
+
// `caps` — 그 **거절 응답**이 실은 능력 광고. 덧붙임의 400·404 가 「서버가 모른다」인지 「이 요청을 거절했다」인지 가른다(C8).
|
|
1216
|
+
const coordKeys = ['rev', 'bytes', 'raw_off', 'raw_key_id', 'raw_win_mac'].filter((k) => k in (r.data || {}));
|
|
1217
|
+
// `comment` — 완료 보고의 400 이 **이미 남긴 글**을 함께 싣는다(근거가 없어 검수로 못 간 경우). 버리면 글 번호를 잃는다(2026-09-26 검토).
|
|
1024
1218
|
throw Object.assign(new Error(r.data.error || `HTTP ${r.status}`),
|
|
1025
1219
|
{ status: r.status, code: String(r.data.code || ''), retry_after: Number(r.data.retry_after) || 0,
|
|
1026
|
-
|
|
1220
|
+
caps: String(capsHeader || ''),
|
|
1221
|
+
auto_upload_at: typeof r.data.auto_upload_at === 'string' ? r.data.auto_upload_at : '',
|
|
1222
|
+
why: typeof r.data.why === 'string' ? r.data.why : '',
|
|
1223
|
+
...(r.data.comment && typeof r.data.comment === 'object' ? { comment: r.data.comment } : {}),
|
|
1224
|
+
...(coordKeys.length ? { coords: Object.fromEntries(coordKeys.map((k) => [k, r.data[k]])) } : {}) });
|
|
1027
1225
|
}
|
|
1028
1226
|
return r.data;
|
|
1029
1227
|
}
|
|
@@ -1269,7 +1467,7 @@ const REPORT_DATA = {
|
|
|
1269
1467
|
+ '· `marg`: {why, key, advice} — **좌측 여백 주석 단. 이것이 본체다.** '
|
|
1270
1468
|
+ '없으면 두 단 본문이 한 단으로 서고, 문서는 「문서처럼 꾸민 웹페이지」가 된다. '
|
|
1271
1469
|
+ 'why 는 **독자가 실제로 던질 질문**(「무엇을 조치해야 하는가?」), '
|
|
1272
|
-
+ 'key 는 이 절에서 하나만 가져간다면 그것, advice 는
|
|
1470
|
+
+ 'key 는 이 절에서 하나만 가져간다면 그것, advice 는 더 볼 곳 — 명사구(「03절 대조표」)나 사물이 주어인 단문(「판정 기준은 부록 1에 있음」), 독자에게 시키는 꼴(「~할 것」「~ 참조」)은 아님. '
|
|
1273
1471
|
+ '채우려고 넣으면 안 넣느니만 못하다.\n'
|
|
1274
1472
|
+ '· `lede`: 절 간지의 도입문 — 다루는 범위와 **다루지 않는 범위**.\n'
|
|
1275
1473
|
+ '· `sub`: 차례 행에 붙는 한 줄 설명. `short`: 측면 색인 레일용 짧은 이름.\n'
|
|
@@ -1366,7 +1564,7 @@ const REPORT_DATA = {
|
|
|
1366
1564
|
type: 'string',
|
|
1367
1565
|
description: 'role: figure 의 **그림 캡션.** 「그림 4-1」 번호 뒤에 붙는다(번호는 자리가 '
|
|
1368
1566
|
+ '매기므로 적지 않는다). 무엇을 보이는 화면인지 한 문장으로 — 본문이 이미 말한 것을 '
|
|
1369
|
-
+ '되풀이하지 말고 **이 화면에서
|
|
1567
|
+
+ '되풀이하지 말고 **이 화면에서 독자가 볼 자리**를 명사형으로 적는다(「8.0 행의 두 날짜와 8.4 행의 종료일」).',
|
|
1370
1568
|
},
|
|
1371
1569
|
source_text: {
|
|
1372
1570
|
type: 'string',
|
|
@@ -1374,18 +1572,49 @@ const REPORT_DATA = {
|
|
|
1374
1572
|
+ '예: 「2026. 8. 24. Galaxy S22+ 실기기 홈 화면」.',
|
|
1375
1573
|
},
|
|
1376
1574
|
role: {
|
|
1377
|
-
type: 'string', enum: ['hero', 'chapter', 'figure', 'eof', 'logo'],
|
|
1575
|
+
type: 'string', enum: ['hero', 'chapter', 'figure', 'eof', 'logo', 'video'],
|
|
1378
1576
|
description: '`logo` 는 **표지 우상단 고객사 마크**다 — provider 는 user-provided, '
|
|
1379
1577
|
+ 'origin 에 제공자를 적는다. 못 받았으면 넣지 말고 data.client 에 이름만 적어라 '
|
|
1380
1578
|
+ '(렌더러가 활자로 세운다).\n'
|
|
1381
1579
|
+ '`eof` 는 **문서 끝 꼬리 카드**다. 안 줘도 된다 — 서버가 기본 카드를 '
|
|
1382
1580
|
+ '끼운다(계약 9절). 이 문서만의 카드가 있을 때만 준다. 그때는 무손실(PNG 또는 '
|
|
1383
|
-
+ '무손실 WebP)이어야 하고 줄이지 않고 원본 크기로
|
|
1581
|
+
+ '무손실 WebP)이어야 하고 줄이지 않고 원본 크기로 실린다.\n'
|
|
1582
|
+
+ '`video` 는 **절 본문 영상**이다(138). 바이트를 여기 싣지 않는다 — 먼저 '
|
|
1583
|
+
+ '`POST /api/media/presign` 으로 올릴 자리를 받아 그 URL 에 PUT 하고, 받은 번호를 '
|
|
1584
|
+
+ '`ref` 에 적는다. 자리는 `figure` 와 같다(sections[].plates·cases[].plates). '
|
|
1585
|
+
+ '영상 저장 연동이 꺼진 환경에서는 발행이 거절된다 — 다른 형식으로 우회하지 않는다.',
|
|
1384
1586
|
},
|
|
1385
1587
|
provider: {
|
|
1386
|
-
type: 'string', enum: ['higgsfield', 'deterministic', 'user-provided'],
|
|
1588
|
+
type: 'string', enum: ['higgsfield', 'deterministic', 'user-provided', 'r2'],
|
|
1387
1589
|
description: 'hero·chapter 는 higgsfield 여야 한다. deterministic 은 role: figure 에 한한다. '
|
|
1388
|
-
+ 'user-provided 는 origin 이 필수다. sample-exception 은 발행 거절
|
|
1590
|
+
+ 'user-provided 는 origin 이 필수다. sample-exception 은 발행 거절 값. '
|
|
1591
|
+
+ '`r2` 는 role: video 와 **짝**이다 — 바이트 대신 ref 로 온다',
|
|
1592
|
+
},
|
|
1593
|
+
// ⚠ 한 이름에 **자리에 따라 다른 두 뜻**이 얹혀 있다. 둘은 provider 로 서로 배타적이고 독자도 키를 만지기
|
|
1594
|
+
// 전에 provider/role 로 먼저 가른다(report_publish.php:544·1642 · report_media.php · gazette.py).
|
|
1595
|
+
// 2026-09-19 에 영상 갈래가 이 정수 선언을 따로 적으면서 아래쪽 불리언 선언과 **같은 객체에 두 번**
|
|
1596
|
+
// 들어갔고, JS 는 뒤엣것을 써서 정수 쪽이 조용히 사라졌다 — 게시 스키마가 `ref` 를 불리언이라고
|
|
1597
|
+
// 광고하는 동안 영상 자산은 `ref: 12` 를 실어 보내고 있었다(2026-09-20 관문이 짚음).
|
|
1598
|
+
// 개명은 하지 않는다: 서버 창구가 응답 본문으로 `ref` 를 안내하고(api.php:3252) 이미 발행된 문서의
|
|
1599
|
+
// source_json 에도 그 이름으로 저장돼 있어, 옮기면 report_media_refs() 가 빈 배열을 내고 청소가
|
|
1600
|
+
// R2 객체를 지운다(되돌릴 수 없다 — 실측으로 확인된 실패 양식). 그래서 **한 벌로 합쳐** 두 뜻을 적는다.
|
|
1601
|
+
ref: {
|
|
1602
|
+
type: ['integer', 'boolean'],
|
|
1603
|
+
description: '자리에 따라 뜻이 갈린다 — **`provider: r2` 이면 영상 자리 번호(정수)**, '
|
|
1604
|
+
+ '그 밖의 그림 자산이면 **세트 참조 스위치(불리언)** 다.\n'
|
|
1605
|
+
+ '· 정수: 영상 자리 번호. **보통은 직접 적지 않는다** — `file` 에 로컬 mp4 절대경로를 주면 '
|
|
1606
|
+
+ '이 도구가 올리고 번호를 채운다. 되읽은 판을 고쳐 되돌려 보낼 때는 받은 값을 그대로 두면 '
|
|
1607
|
+
+ '다시 안 올린다(빼면 서버가 저장된 값을 잇는다).\n'
|
|
1608
|
+
+ '· 불리언: 거짓이면 **세트 참조 없이 단독으로** 그린다. 기본은 참 — hero 를 먼저 그려 그것을 '
|
|
1609
|
+
+ '참조로 나머지를 이어야 한 세트로 보인다(참조 없이 내면 장마다 인물이 달라진다). '
|
|
1610
|
+
+ 'hero 자신에게는 뜻이 없다.',
|
|
1611
|
+
},
|
|
1612
|
+
// 영상 썸네일 판(d9dde6f7) — 렌더러가 읽는 칸(gazette.py `<video data-thumbs…>`)이라 계약에 적는다(tools/contract-drift.py).
|
|
1613
|
+
thumbs: {
|
|
1614
|
+
type: ['object', 'null'],
|
|
1615
|
+
description: '`provider: r2`(영상) 자산의 **시크바 썸네일 판** — 열람 화면에서 시크바를 끌 때 그 자리 그림을 보인다. '
|
|
1616
|
+
+ '**보통은 적지 않는다** — `file` 에 로컬 mp4 를 주면 이 도구가 발행 때 ffmpeg 로 굽는다(ffmpeg 가 없으면 판 없이 간다). '
|
|
1617
|
+
+ '`null` 이면 판을 뺀다. 되읽은 판을 고쳐 되돌려 보낼 때는 받은 값을 그대로 두면 서버가 저장된 판을 잇는다.',
|
|
1389
1618
|
},
|
|
1390
1619
|
alt: { type: 'string', description: '설명적 대체 텍스트 — 접근성 계약. 그림과 함께 다닌다' },
|
|
1391
1620
|
origin: { type: 'string', description: '생성 job 식별자·.mmd 원본 경로·제공자 이름' },
|
|
@@ -1424,12 +1653,7 @@ const REPORT_DATA = {
|
|
|
1424
1653
|
type: 'string', enum: ['16:9', '4:3', '3:4', '1:1', '3:2', '2:3', '9:16'],
|
|
1425
1654
|
description: '생략하면 역할이 정한다 — hero 16:9 · 그 밖 4:3.',
|
|
1426
1655
|
},
|
|
1427
|
-
ref
|
|
1428
|
-
type: 'boolean',
|
|
1429
|
-
description: '거짓이면 **세트 참조 없이 단독으로** 그린다. 기본은 참 — hero 를 먼저 '
|
|
1430
|
-
+ '그려 그것을 참조로 나머지를 이어야 한 세트로 보인다(참조 없이 내면 장마다 '
|
|
1431
|
-
+ '인물이 달라진다). hero 자신에게는 뜻이 없다.',
|
|
1432
|
-
},
|
|
1656
|
+
// (`ref` 는 위 provider 옆으로 합쳤다 — 한 객체에 같은 키를 두 번 적으면 JS 가 앞엣것을 조용히 버린다)
|
|
1433
1657
|
source: {
|
|
1434
1658
|
type: 'string',
|
|
1435
1659
|
description: '이미 그린 그림의 원본 https 주소(허용 호스트 목록 안). **이 도구의 프로세스가** '
|
|
@@ -1459,8 +1683,8 @@ const REPORT_DATA = {
|
|
|
1459
1683
|
symptom: { type: 'string' },
|
|
1460
1684
|
cause: { type: 'string' },
|
|
1461
1685
|
fix: { type: 'string' },
|
|
1462
|
-
how_to_check: { type: 'string' },
|
|
1463
|
-
remaining: { type: 'string', description: '
|
|
1686
|
+
how_to_check: { type: 'string', description: '고객이 화면에서 볼 수 있는 결과 — 어디를 열면 무엇이 보이는지(주어는 화면·값, 독자에게 시키는 문장 아님)' },
|
|
1687
|
+
remaining: { type: 'string', description: '아직 닫히지 않은 것 — 미확인·미결인 사실과 운영에 아직 안 나간 우리 작업. 독자에게 부탁할 것은 open_items 로(선택)' },
|
|
1464
1688
|
source_url: { type: 'string', description: 'http(s) 제보 원본 링크(선택)' },
|
|
1465
1689
|
quotes: {
|
|
1466
1690
|
type: 'array',
|
|
@@ -1511,6 +1735,16 @@ const REPORT_DATA = {
|
|
|
1511
1735
|
* 심볼릭 링크라 리터럴 경로로 만들면 엉뚱한 디렉터리에 놓인다(그렇게 세 번 틀렸다)
|
|
1512
1736
|
*/
|
|
1513
1737
|
|
|
1738
|
+
/**
|
|
1739
|
+
* 클라이언트 검열 **규칙판**(DESIGN-session-delta-sync §2.8 · C6). 사람이 올리는 상수다 — 검열 **출력 바이트**를 바꾸는
|
|
1740
|
+
* 원천(`mcp/scrub-rev.lock` 의 목록: SECRET_SHAPES·MASK·DROP_TYPES·KEEP_ATTACHMENT_PREFIX·isKeptAttachment·
|
|
1741
|
+
* maskHomeInPick·maskSecrets·maskDeep·relHome·scrubTranscript)을 고치면 이 판을 올리고 lock 에 새 줄을 덧붙인다.
|
|
1742
|
+
* smoke 의 「규칙판 lock」 절이 원문 지문(주석·공백 걷은 토큰)과 판을 대조한다 — 원천이 바뀌었는데 판이 그대로면 빨갛다.
|
|
1743
|
+
* ⚠ 서버 `AGENT_SCRUB_REV`(r…)와는 **다른 이름공간**이다(m…). 전체 PUT 은 이 값을 좌표의 규칙판으로 싣고, 서버는 행에
|
|
1744
|
+
* 자기 판을 적어 응답 `scrub_rev` 로 돌려준다 — 대응표는 둘 다 적는다(scrub_rev · srv_scrub_rev).
|
|
1745
|
+
*/
|
|
1746
|
+
const SCRUB_REV = 'm1';
|
|
1747
|
+
|
|
1514
1748
|
/** 비밀 **모양** — `www/portal/lib/agent_session.php` 의 `agent_secret_shapes()` 와 **같아야 한다.**
|
|
1515
1749
|
* PHP 와 JS 는 코드를 공유할 수 없으므로 `mcp/smoke.mjs` 가 두 목록의 이름을 대조한다. */
|
|
1516
1750
|
const SECRET_SHAPES = [
|
|
@@ -1630,10 +1864,22 @@ function cwdMode(counts) {
|
|
|
1630
1864
|
return best;
|
|
1631
1865
|
}
|
|
1632
1866
|
|
|
1633
|
-
|
|
1867
|
+
/**
|
|
1868
|
+
* @param {string} raw 원본 전사의 **줄 경계에서 끊긴** 텍스트(부르는 쪽이 마지막 `\n` 까지 자른다 — §2.1)
|
|
1869
|
+
* @param {{delta?: boolean, hist?: Object<string, number>}} [opts]
|
|
1870
|
+
* `delta` — 변화분 모드(§2.2): 남는 줄이 0이어도 예외 대신 `{jsonl:'', lines:0, …}`. 전체 모드의 「빈 트랜스크립트」 예외는 그대로.
|
|
1871
|
+
* `hist` — 앞 구간까지 센 `cwd_hist`(raw cwd → 셈). 이어 세면 `cwd`·`cwd_raw`·`cwd_hist` 가 전체를 한 번에 센 것과 같다.
|
|
1872
|
+
* 출력 줄은 **그 줄만의 함수**다 — 그래서 [0,a)·[a,b)·… 로 잘라 검열한 jsonl 을 이으면 전체를 한 번에 검열한 바이트와 같다.
|
|
1873
|
+
* `cwd_hist` 는 **자르지 않은 전부**다(.genetics-private 판정이 열쇠 전부를 본다 — 예전 상위 64 자르기는 65위 아래를 흘렸다).
|
|
1874
|
+
*/
|
|
1875
|
+
function scrubTranscript(raw, opts = {}) {
|
|
1876
|
+
const delta = opts?.delta === true;
|
|
1634
1877
|
const out = [];
|
|
1635
|
-
|
|
1636
|
-
|
|
1878
|
+
// 폴더 identity 는 realpath 뒤에 잰다(§3.2) — 이 기계 안에서만 쓰고 보내지 않는다
|
|
1879
|
+
// ⚠ 동수 판정은 「처음 본 차례」를 쓴다(cwdMode). `Object.fromEntries`·JSON 왕복은 **정수 모양 열쇠**를 앞으로 옮겨 그 차례를
|
|
1880
|
+
// 흐트러뜨린다 — 실제 cwd 는 절대 경로라 그런 열쇠가 안 생기지만, 대응표의 hist 를 이어 셀 때(C8) 이 전제에 기대고 있다.
|
|
1881
|
+
const cwdsRaw = new Map(Object.entries(opts?.hist && typeof opts.hist === 'object' ? opts.hist : {})
|
|
1882
|
+
.filter(([k, v]) => k && Number.isInteger(v) && v > 0));
|
|
1637
1883
|
let redacted = 0, dropped = 0, no = 0;
|
|
1638
1884
|
for (const line of raw.split('\n')) {
|
|
1639
1885
|
no++;
|
|
@@ -1649,8 +1895,6 @@ function scrubTranscript(raw) {
|
|
|
1649
1895
|
* (그것도 152줄을 싣는다) 버린 뒤에 세면 표본이 **조용히** 줄어든다. 순서가 곧 방어다.
|
|
1650
1896
|
*/
|
|
1651
1897
|
if (typeof r.cwd === 'string' && r.cwd) {
|
|
1652
|
-
const k = relHome(r.cwd);
|
|
1653
|
-
cwds.set(k, (cwds.get(k) || 0) + 1);
|
|
1654
1898
|
cwdsRaw.set(r.cwd, (cwdsRaw.get(r.cwd) || 0) + 1);
|
|
1655
1899
|
}
|
|
1656
1900
|
if (DROP_TYPES.has(r.type) && !isKeptAttachment(r)) { dropped++; continue; }
|
|
@@ -1703,11 +1947,15 @@ function scrubTranscript(raw) {
|
|
|
1703
1947
|
redacted += extra;
|
|
1704
1948
|
out.push(JSON.stringify(safe));
|
|
1705
1949
|
}
|
|
1706
|
-
if (!out.length) throw new Error('빈 트랜스크립트입니다.');
|
|
1707
|
-
//
|
|
1708
|
-
|
|
1709
|
-
const
|
|
1710
|
-
|
|
1950
|
+
if (!out.length && !delta) throw new Error('빈 트랜스크립트입니다.');
|
|
1951
|
+
// 집 경로를 접은 셈은 원문 셈에서 낸다 — 이어 센 hist 에서도 전체와 같은 최빈 자리가 나온다(처음 본 차례도 같다).
|
|
1952
|
+
const cwds = new Map();
|
|
1953
|
+
for (const [k, v] of cwdsRaw) { const h = relHome(k); cwds.set(h, (cwds.get(h) || 0) + v); }
|
|
1954
|
+
// `cwds_raw` 는 **이 대화가 거쳐 간 자리 전부**다(빈도순 · 자르지 않는다). 최빈 자리 하나만 보면 세션의 일부가
|
|
1955
|
+
// `.genetics-private` 폴더에서 돌아도 그 대화가 통째로 올라간다(개인정보 검토 m1). 판정의 정본은 `cwd_hist` 의 열쇠다.
|
|
1956
|
+
const rawAll = [...cwdsRaw.entries()].sort((a, b) => b[1] - a[1]).map(([k]) => k);
|
|
1957
|
+
return { jsonl: out.length ? out.join('\n') + '\n' : '', lines: out.length, dropped, redacted,
|
|
1958
|
+
cwd: cwdMode(cwds), cwd_raw: cwdMode(cwdsRaw), cwds_raw: rawAll, cwd_hist: Object.fromEntries(cwdsRaw) };
|
|
1711
1959
|
}
|
|
1712
1960
|
|
|
1713
1961
|
/*
|
|
@@ -2015,6 +2263,768 @@ function splitAtLines(text, max) {
|
|
|
2015
2263
|
return out.length ? out : [text];
|
|
2016
2264
|
}
|
|
2017
2265
|
|
|
2266
|
+
/* ── 좌표·기계 키·대응표 (DESIGN-session-delta-sync §2.1·§2.9·§2.11 · C6) ────────────────────────────
|
|
2267
|
+
*
|
|
2268
|
+
* 이음매는 **원본 전사의 바이트 오프셋**이다. `raw_off` 는 늘 마지막 `\n` 바로 뒤에 멈춘다(쓰는 중인 반쪽 줄은 다음 바퀴).
|
|
2269
|
+
* 기반의 정체는 기기 이름이 아니라 **기계 키의 지문**이 보증한다 — `deviceId` 는 유일하지도 안정적이지도 않다.
|
|
2270
|
+
* · 키: `<설정 디렉터리>/push/_key` — 0600, 32바이트 난수, **처음 FULL 을 보낼 때** 만든다(모듈을 불러오는 것만으로는 안 만든다
|
|
2271
|
+
* — 시험들이 진짜 HOME 인 채 이 파일을 import 한다).
|
|
2272
|
+
* · raw_key_id = HMAC(key, 'genetics-push/key-id') 앞 16hex — 이 기계의 좌표인지 가르는 이름표일 뿐이다.
|
|
2273
|
+
* · raw_head_mac = HMAC(key, 원본[0, min(64KB, to))) ← 64KB 보다 짧은 전사는 [0,to) 다(덧붙임이 같은 규칙으로 잰다).
|
|
2274
|
+
* · raw_win_mac = HMAC(key, 원본[max(0, to−64KB), to))
|
|
2275
|
+
* · 원본 바이트의 맨 sha 는 **서버에 안 보낸다**(privacy) — 서버는 키가 없어 지문으로 원문을 대조해 볼 수 없다.
|
|
2276
|
+
* 대응표 `<설정 디렉터리>/push/<uuid>.json` 은 **좌표 캐시일 뿐**이다 — 정책 상태(간격·백오프·막음)는 색인 `auto.*` 에 그대로 둔다.
|
|
2277
|
+
* 자리는 자격 파일 옆(`dirname(CRED_FILE)`, 색인과 같은 규칙) — `GENETICS_CRED` 하나로 시험이 격리된다. `GENETICS_PUSH_DIR` 는
|
|
2278
|
+
* 같은 프로세스 안에서 자리를 갈아 끼우는 시험용 손잡이다(부를 때마다 읽는다).
|
|
2279
|
+
*/
|
|
2280
|
+
const PUSH_MAC_WIN = 64 * 1024;
|
|
2281
|
+
/** 거쳐 간 자리(cwd_hist 열쇠)가 이보다 많으면 자동 올리기는 닫힌 쪽으로 멈춘다(§2.2 `cwd_hist_overflow`). */
|
|
2282
|
+
const CWD_HIST_MAX = 4096;
|
|
2283
|
+
const PUSH_FULL_LOG_MAX = 16;
|
|
2284
|
+
|
|
2285
|
+
function pushDir() {
|
|
2286
|
+
return process.env.GENETICS_PUSH_DIR || path.join(path.dirname(CRED_FILE), 'push');
|
|
2287
|
+
}
|
|
2288
|
+
|
|
2289
|
+
let pushKeyMemo = null; // { file, key } — 자리가 바뀌면(시험) 다시 읽는다
|
|
2290
|
+
/**
|
|
2291
|
+
* 기계 키 — 없으면 만든다(`create`). 못 만들거나 못 읽으면 **null**: 부르는 쪽은 좌표 없이 옛 봉투로 보낸다(예외를 안 낸다).
|
|
2292
|
+
* 만들기는 임시 파일(0600)을 다 채운 뒤 `link` 로 건다 — 이미 있으면 EEXIST 로 지고 남의 키를 읽는다(경합에서 반쪽 키를 안 본다).
|
|
2293
|
+
*/
|
|
2294
|
+
function pushKey(create = true) {
|
|
2295
|
+
const dir = pushDir();
|
|
2296
|
+
const file = path.join(dir, '_key');
|
|
2297
|
+
if (pushKeyMemo && pushKeyMemo.file === file) return pushKeyMemo.key;
|
|
2298
|
+
let key = null;
|
|
2299
|
+
try { key = fs.readFileSync(file); } catch (e) {
|
|
2300
|
+
if (e?.code !== 'ENOENT') warnOnce('push-key-read', `기계 키(${file})를 못 읽어 좌표 없이 올립니다: ${e?.code || e}`);
|
|
2301
|
+
if (e?.code !== 'ENOENT' || !create) return null;
|
|
2302
|
+
}
|
|
2303
|
+
if (!key) {
|
|
2304
|
+
const tmp = `${file}.tmp-${process.pid}-${crypto.randomBytes(4).toString('hex')}`;
|
|
2305
|
+
try {
|
|
2306
|
+
fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
2307
|
+
const fd = fs.openSync(tmp, 'wx', 0o600);
|
|
2308
|
+
try { fs.fchmodSync(fd, 0o600); fs.writeSync(fd, crypto.randomBytes(32)); fs.fsyncSync(fd); } finally { fs.closeSync(fd); }
|
|
2309
|
+
try { fs.linkSync(tmp, file); } catch (e) { if (e?.code !== 'EEXIST') throw e; }
|
|
2310
|
+
} catch (e) {
|
|
2311
|
+
// 좌표 없이도 올리기는 된다(개인정보 쪽으로는 안전한 방향) — 다만 말없이 영영 꺼지지 않게 한 줄은 남긴다
|
|
2312
|
+
warnOnce('push-key-make', `기계 키를 못 만들어 좌표 없이 올립니다(${dir}): ${e?.code || e}`);
|
|
2313
|
+
return null;
|
|
2314
|
+
} finally { try { fs.rmSync(tmp, { force: true }); } catch { /* */ } }
|
|
2315
|
+
try { key = fs.readFileSync(file); } catch { return null; }
|
|
2316
|
+
}
|
|
2317
|
+
if (key.length !== 32) { // 망가진 키는 안 쓴다(갈아 끼우지도 않는다 — 좌표 없이 FULL 로 산다)
|
|
2318
|
+
warnOnce('push-key-bad', `기계 키(${file})가 32바이트가 아니라 좌표 없이 올립니다 — 지우면 다음 올리기에 새로 만듭니다.`);
|
|
2319
|
+
return null;
|
|
2320
|
+
}
|
|
2321
|
+
// 백업에서 되살리거나 손으로 복사한 키는 0644 일 수 있다 — 쓰기 전에 조인다(키·자리 모두). 못 조여도 올리기는 막지 않는다.
|
|
2322
|
+
for (const [p, want] of [[file, 0o600], [dir, 0o700]]) {
|
|
2323
|
+
try {
|
|
2324
|
+
if (fs.statSync(p).mode & 0o077) { fs.chmodSync(p, want); warnOnce(`push-key-mode:${p}`, `${p} 의 권한이 넓어 ${want.toString(8)} 으로 조였습니다.`); }
|
|
2325
|
+
} catch { /* */ }
|
|
2326
|
+
}
|
|
2327
|
+
pushKeyMemo = { file, key };
|
|
2328
|
+
return key;
|
|
2329
|
+
}
|
|
2330
|
+
function pushMac(key, buf) { return crypto.createHmac('sha256', key).update(buf).digest('hex'); }
|
|
2331
|
+
function pushKeyId(key) { return pushMac(key, 'genetics-push/key-id').slice(0, 16); }
|
|
2332
|
+
|
|
2333
|
+
/** FULL 이 싣는 `raw` 좌표 — 서버 `agent_session_coords_parse()` 의 모양(`to` 는 JSON 정수 · MAC 은 소문자 64hex). */
|
|
2334
|
+
function pushCoords(key, bytes, to) {
|
|
2335
|
+
return {
|
|
2336
|
+
key_id: pushKeyId(key), to,
|
|
2337
|
+
head_mac: pushMac(key, bytes.subarray(0, Math.min(PUSH_MAC_WIN, to))),
|
|
2338
|
+
win_mac_to: pushMac(key, bytes.subarray(Math.max(0, to - PUSH_MAC_WIN), to)),
|
|
2339
|
+
};
|
|
2340
|
+
}
|
|
2341
|
+
|
|
2342
|
+
function ledgerFile(uuid) {
|
|
2343
|
+
const u = String(uuid || '').toLowerCase();
|
|
2344
|
+
return /^[0-9a-f][0-9a-f-]{7,63}$/.test(u) ? path.join(pushDir(), `${u}.json`) : '';
|
|
2345
|
+
}
|
|
2346
|
+
function ledgerRead(uuid) {
|
|
2347
|
+
const f = ledgerFile(uuid);
|
|
2348
|
+
if (!f) return null;
|
|
2349
|
+
try { const o = JSON.parse(fs.readFileSync(f, 'utf8')); return o && typeof o === 'object' && !Array.isArray(o) ? o : null; } catch { return null; }
|
|
2350
|
+
}
|
|
2351
|
+
function ledgerWrite(uuid, obj) {
|
|
2352
|
+
const f = ledgerFile(uuid);
|
|
2353
|
+
if (!f) return false;
|
|
2354
|
+
fs.mkdirSync(path.dirname(f), { recursive: true, mode: 0o700 });
|
|
2355
|
+
writeAtomic(f, JSON.stringify(obj), 0o600); // 임시 파일 + rename(§2.11) — 반쪽 대응표를 읽는 창이 없다
|
|
2356
|
+
return true;
|
|
2357
|
+
}
|
|
2358
|
+
function ledgerDrop(uuid) {
|
|
2359
|
+
const f = ledgerFile(uuid);
|
|
2360
|
+
if (f) { try { fs.rmSync(f, { force: true }); } catch { /* 캐시일 뿐이다 */ } }
|
|
2361
|
+
}
|
|
2362
|
+
|
|
2363
|
+
/**
|
|
2364
|
+
* FULL 성공 뒤 대응표를 세운다 — **서버가 그 좌표를 세웠다고 답한 때만**: 응답 `raw_off` 가 정수이고 보낸 `to` 와 같으며,
|
|
2365
|
+
* `raw_win_mac` 이 보낸 값과 **같을 때**(그 MAC 은 이 기계 키의 HMAC 이라, 같으면 서버 행의 key_id 도 이 키다 — 성공 응답에는
|
|
2366
|
+
* key_id 칸이 없어 이것으로 대신 잰다). 칸이 없거나(옛 서버) null 이면(누적 상한·cwd 비밀 모양) 옛 대응표를 **지운다** —
|
|
2367
|
+
* 서버 행의 좌표가 방금 비었거나 원래 없으니 남은 캐시는 거짓 기반이다. 실패해도 올리기는 성공이다(캐시일 뿐).
|
|
2368
|
+
* 좌표 없이 보냈으면(`sent` null — 키 없음·Cursor 의 다 쓴 끝 줄) 역시 지운다.
|
|
2369
|
+
*/
|
|
2370
|
+
function ledgerAfterFull(uuid, r, sent, ctx) {
|
|
2371
|
+
try {
|
|
2372
|
+
if (!sent || !Number.isInteger(r?.raw_off) || r.raw_off !== sent.to || !Number.isInteger(r?.rev)
|
|
2373
|
+
|| r.raw_win_mac !== sent.win_mac_to) {
|
|
2374
|
+
ledgerDrop(uuid);
|
|
2375
|
+
return false;
|
|
2376
|
+
}
|
|
2377
|
+
return ledgerWrite(uuid, {
|
|
2378
|
+
v: 1, uuid, tenant_id: Number(r.tenant_id) || 0, key_id: sent.key_id, rev: r.rev,
|
|
2379
|
+
bytes: Number.isInteger(r.bytes) ? r.bytes : null, raw_off: r.raw_off,
|
|
2380
|
+
head_mac: sent.head_mac, win_mac: sent.win_mac_to, ino: ctx.ino, dev: ctx.dev,
|
|
2381
|
+
/*
|
|
2382
|
+
* ★ `head_len` — **서버가 되돌려 주지 않는 단 하나의 좌표**(C8). 덧붙임 관문은 `raw.head_mac !== row.raw_head_mac` 이면
|
|
2383
|
+
* base_moved 인데, 성공하면 서버는 **보낸 head_mac 을 그대로 다시 적는다**(agent_session_append.php:241). 즉 그 지문의
|
|
2384
|
+
* 창 길이는 **첫 FULL 의 `to` 로 세션 내내 고정**된다 — 전사가 자란 뒤 `min(64K, raw_off)` 로 다시 재면 첫 FULL 이
|
|
2385
|
+
* 64KB 미만이던 대화는 값이 갈려 **영원히 base_moved** 다(그리고 까닭이 「좌표가 어긋났다」로만 보인다).
|
|
2386
|
+
* 그래서 창 길이를 여기 적고, 덧붙임은 **그 길이로** 재서 대응표의 값과 견준 뒤 **대응표의 값을 싣는다**.
|
|
2387
|
+
*/
|
|
2388
|
+
head_len: Math.min(PUSH_MAC_WIN, sent.to),
|
|
2389
|
+
scrub_rev: SCRUB_REV, srv_scrub_rev: typeof r.scrub_rev === 'string' ? r.scrub_rev : null,
|
|
2390
|
+
ck_off: sent.to, ck_sha: ctx.ck_sha, cwd_hist: ctx.cwd_hist,
|
|
2391
|
+
line_off: Number.isInteger(ctx.line_off) ? ctx.line_off : 0, // [0,raw_off) 의 원본 줄 수 — 변화분 오류의 줄 번호를 사람 말로 되돌린다
|
|
2392
|
+
head: { id: String(r.head_id || ''), ms: Number(r.head_ms) || 0 },
|
|
2393
|
+
/*
|
|
2394
|
+
* ★ **`full_log` 는 여기 없다 — 색인 `auto.full_log` 로 옮겼다**(C8 · C7 이 남긴 숙제 둘의 처방). 까닭 둘:
|
|
2395
|
+
* ① 이 함수는 좌표가 안 오면 대응표를 **통째로 지운다**(위) — 예산의 재료가 그때마다 0 으로 되돌아갔다.
|
|
2396
|
+
* ② 이 함수는 **사람의 push 도 안 가린다** — 사람이 두 번 올리면 그 대화의 자동 갱신이 한 시간 멎었다.
|
|
2397
|
+
* 기록하는 자는 이제 **입구 하나**(`autoEntrance`)이고, 기록하는 때는 **진짜 FULL(재기반)이 성공한 때**뿐이다.
|
|
2398
|
+
* 설계 §2.11 의 「정책 상태는 대응표에 두지 않는다 — 색인 `auto.*` 에 둔다」와도 같은 쪽이다.
|
|
2399
|
+
*/
|
|
2400
|
+
});
|
|
2401
|
+
} catch { return false; }
|
|
2402
|
+
}
|
|
2403
|
+
|
|
2404
|
+
/* ── 능력 광고 캐시 (DESIGN-session-delta-sync §6.1 · C8) ────────────────────────────────────────────
|
|
2405
|
+
*
|
|
2406
|
+
* 새 서버는 **세션 블록 맨 앞**에서 `X-Genetics-Session-Caps: append1` 을 한 번 세운다(api.php:1304) — 자격 403 보다도,
|
|
2407
|
+
* `api_out` 을 안 지나는 이진 심박 응답보다도 앞이다. **광고를 본 적 없는 서버에는 덧붙임을 보내지 않는다.**
|
|
2408
|
+
* 판정의 규율 셋(전부 실측이 근거다):
|
|
2409
|
+
* ① 머리가 있으면 **참**이다 — 그 origin 에 붙박이로 적는다(메모리 + `push/_caps.json`).
|
|
2410
|
+
* ② 「없으니 옛 서버」는 **세션 경로의 JSON 응답이면서 401 이 아닐 때만** 적는다(1시간). ⚠ **401 에는 광고가 없다**
|
|
2411
|
+
* (전역 인증이 세션 블록보다 앞이다 — curl 실측) — 401 하나로 거짓을 캐시하면 토큰이 만료된 순간 그 origin 이
|
|
2412
|
+
* 영영 FULL 전용이 된다.
|
|
2413
|
+
* ③ 5xx·타임아웃·망 오류·JSON 이 아닌 응답은 **아무 판정도 아니다**(위 `api()` 의 주석 — 깨진 200 이 그 갈래다).
|
|
2414
|
+
* ④ 「없으니 옛 서버」는 **우리 PHP 가 답한 응답**에서만 적는다 — 표는 `X-Genetics-Min-Client`(api.php:214 가 **모든 `/api/*`,
|
|
2415
|
+
* 4xx 포함**에 세운다 · 능력 광고보다 먼저 들어온 머리다). 그 머리도 없는 4xx 는 가장자리(Caddy·WAF·CDN 의 403·404·429)의
|
|
2416
|
+
* 답이라 서버에 대해 아무것도 말하지 않는다(C8 반박 cost③ — 전에는 그 하나가 origin 을 한 시간 FULL 전용으로 만들었다).
|
|
2417
|
+
* ⑤ `/append` 가 광고 **없이** 400·404·405 로 거절되면(`capsAppendGone`) 그 거짓은 **1시간 동안 광고로도 못 덮는다**. 곧이어 도는
|
|
2418
|
+
* FULL 의 응답이 광고를 싣기 때문이다 — 안 그러면 「덧붙임만 죽은 서버」(그 경로만 막은 프록시·반쪽 배포)에서 바퀴마다
|
|
2419
|
+
* `/append` 를 다시 두드리고 FULL 로 내려가기를 되풀이한다(C8 반박 fail F1·equiv ④ — 거짓이 같은 바퀴 안에 지워졌다).
|
|
2420
|
+
* 서버의 규칙판(`scrub_rev`)도 같은 자리에 적는다 — 그것은 **서버마다 하나**인 값이고, 대응표를 잃은 따라잡기가
|
|
2421
|
+
* 덧붙임 봉투를 지으려면 그 값이 있어야 한다(대응표의 `srv_scrub_rev` 가 유일한 재료였다).
|
|
2422
|
+
*/
|
|
2423
|
+
const CAPS_FALSE_TTL_MS = 3600_000;
|
|
2424
|
+
const CAPS_REV_RE = /^[A-Za-z0-9._-]{1,32}$/;
|
|
2425
|
+
let capsMemo = null; // origin → { append: boolean, at: number, gone?: number, scrub_rev?: string }
|
|
2426
|
+
let capsMemoDir = ''; // 그 메모가 읽어 온 자리 — 자리가 바뀌면(시험의 GENETICS_PUSH_DIR) 다시 읽는다
|
|
2427
|
+
|
|
2428
|
+
function capsFile() { return path.join(pushDir(), '_caps.json'); }
|
|
2429
|
+
function capsOrigin() { try { return new URL(BASE).origin; } catch { return String(BASE || ''); } }
|
|
2430
|
+
function capsLoad() {
|
|
2431
|
+
const dir = pushDir();
|
|
2432
|
+
if (capsMemo && capsMemoDir === dir) return capsMemo;
|
|
2433
|
+
capsMemo = new Map();
|
|
2434
|
+
capsMemoDir = dir;
|
|
2435
|
+
try {
|
|
2436
|
+
const o = JSON.parse(fs.readFileSync(capsFile(), 'utf8'));
|
|
2437
|
+
for (const [k, v] of Object.entries(o?.origins && typeof o.origins === 'object' ? o.origins : {})) {
|
|
2438
|
+
if (v && typeof v === 'object' && typeof v.append === 'boolean') {
|
|
2439
|
+
capsMemo.set(k, { append: v.append, at: Number(v.at) || 0, ...(Number(v.gone) > 0 ? { gone: Number(v.gone) } : {}),
|
|
2440
|
+
...(CAPS_REV_RE.test(String(v.scrub_rev || '')) ? { scrub_rev: String(v.scrub_rev) } : {}) });
|
|
2441
|
+
}
|
|
2442
|
+
}
|
|
2443
|
+
} catch { /* 없거나 깨졌으면 **모르는 것**으로 — 캐시일 뿐이고 모르면 FULL 이다(닫는 쪽) */ }
|
|
2444
|
+
return capsMemo;
|
|
2445
|
+
}
|
|
2446
|
+
/**
|
|
2447
|
+
* ⚠ **자리를 만들지 않는다.** `push/` 는 기계 키와 대응표의 집이고 그 자리는 FULL 이 키를 만들 때 선다. 광고 하나 때문에 자리를
|
|
2448
|
+
* 만들면 **dry_run 이 자리를 안 만든다**는 계약(C6)이 깨진다 — 광고를 싣는 서버에 dry_run 을 하면 `push/_caps.json` 이 생겼다
|
|
2449
|
+
* (C8 반박 fail F2·cost ⑤ · smoke 의 그 단언은 가짜 서버가 광고를 안 내서 빨개질 수 없었다). 자리가 없으면 메모리로 족하다 —
|
|
2450
|
+
* 첫 FULL 이 키를 만들면 그다음 응답부터 적힌다.
|
|
2451
|
+
*/
|
|
2452
|
+
function capsSave() {
|
|
2453
|
+
try {
|
|
2454
|
+
if (!fs.existsSync(pushDir())) return;
|
|
2455
|
+
writeAtomic(capsFile(), JSON.stringify({ v: 1, origins: Object.fromEntries(capsLoad()) }), 0o600);
|
|
2456
|
+
} catch { /* 못 적어도 이 프로세스는 메모리 캐시로 산다 */ }
|
|
2457
|
+
}
|
|
2458
|
+
/**
|
|
2459
|
+
* 응답 하나가 이 서버에 대해 말해 주는 것 — 규율은 위 머리말. `api()` 가 JSON 으로 읽힌 응답에서만 부른다.
|
|
2460
|
+
* @param {boolean} [ours] 우리 PHP 가 답했나(`X-Genetics-Min-Client` 가 있었나) — 거짓이면 「광고 없음」을 판정으로 안 쓴다(④)
|
|
2461
|
+
*/
|
|
2462
|
+
function capsNote(pathname, status, header, data, ours = true) {
|
|
2463
|
+
if (!/^\/api\/sessions(\/|$)/.test(String(pathname || ''))) return;
|
|
2464
|
+
const org = capsOrigin();
|
|
2465
|
+
if (!org) return;
|
|
2466
|
+
const m = capsLoad();
|
|
2467
|
+
const was = m.get(org) || null;
|
|
2468
|
+
const has = String(header || '').split(/[\s,]+/).filter(Boolean).includes('append1');
|
|
2469
|
+
// 서버 판(`scrub_rev`)은 좌표를 실은 전체 PUT 의 성공 응답이 준다 — 모양이 맞을 때만 받는다.
|
|
2470
|
+
const rev = typeof data?.scrub_rev === 'string' && CAPS_REV_RE.test(data.scrub_rev) ? data.scrub_rev : (was?.scrub_rev || '');
|
|
2471
|
+
const s = Number(status) || 0;
|
|
2472
|
+
const now = Date.now();
|
|
2473
|
+
// ⑤ `/append` 가 광고 없이 거절된 뒤 1시간은 광고도 그 거짓을 못 덮는다(그 시각이 `gone`).
|
|
2474
|
+
const goneHold = was?.append === false && Number(was.gone) > 0 && now - Number(was.gone) < CAPS_FALSE_TTL_MS;
|
|
2475
|
+
let next = null;
|
|
2476
|
+
if (has) next = goneHold ? null : { append: true, at: now };
|
|
2477
|
+
else if (ours && s !== 401 && ((s >= 200 && s < 300) || (s >= 400 && s < 500))) {
|
|
2478
|
+
next = { append: false, at: now, ...(goneHold ? { gone: Number(was.gone) } : {}) };
|
|
2479
|
+
}
|
|
2480
|
+
if (!next) { if (rev && rev !== was?.scrub_rev) { m.set(org, { ...(was || { append: false, at: 0 }), scrub_rev: rev }); capsSave(); } return; }
|
|
2481
|
+
if (rev) next.scrub_rev = rev;
|
|
2482
|
+
// 같은 답이 되풀이되면 파일을 다시 안 적는다 — 거짓은 1시간마다 한 번만 새로 적는다(그 TTL 이 새로 올라간 서버를 다시 본다).
|
|
2483
|
+
if (was && was.append === next.append && was.scrub_rev === next.scrub_rev
|
|
2484
|
+
&& (next.append === true || now - was.at < CAPS_FALSE_TTL_MS)) return;
|
|
2485
|
+
m.set(org, next);
|
|
2486
|
+
capsSave();
|
|
2487
|
+
}
|
|
2488
|
+
/** 이 서버가 덧붙임을 받는다고 **광고했나** — 모르면 거짓이다(닫는 쪽: FULL). */
|
|
2489
|
+
function capsAppend() { return capsLoad().get(capsOrigin())?.append === true; }
|
|
2490
|
+
/** 이 서버의 규칙판(`AGENT_SCRUB_REV`) — 못 봤으면 ''. 덧붙임 봉투의 `scrub_rev` 는 **클라이언트 판(m1)이 아니라 이것**이다. */
|
|
2491
|
+
function capsScrubRev() { return String(capsLoad().get(capsOrigin())?.scrub_rev || ''); }
|
|
2492
|
+
/**
|
|
2493
|
+
* `/append` 가 **광고 없이** 400·404·405 — 이 서버(또는 그 앞의 무엇)는 덧붙임을 모른다(§5.1). 1시간 거짓으로 덮고 이번 바퀴는 FULL.
|
|
2494
|
+
* ⚠ 그 1시간은 **광고로도 못 덮는다**(`gone` · 머리말 ⑤) — 그래서 그동안은 `/append` 를 안 두드리고 옛 서버처럼 FULL 만 간다.
|
|
2495
|
+
*/
|
|
2496
|
+
function capsAppendGone() {
|
|
2497
|
+
const m = capsLoad();
|
|
2498
|
+
const was = m.get(capsOrigin());
|
|
2499
|
+
const now = Date.now();
|
|
2500
|
+
m.set(capsOrigin(), { append: false, at: now, gone: now, ...(was?.scrub_rev ? { scrub_rev: was.scrub_rev } : {}) });
|
|
2501
|
+
capsSave();
|
|
2502
|
+
}
|
|
2503
|
+
|
|
2504
|
+
/* ── 덧붙임 길 (DESIGN-session-delta-sync §2.10 · C8) ────────────────────────────────────────────── */
|
|
2505
|
+
|
|
2506
|
+
/** 【선언】 한 조각의 **JSON 봉투** 상한. 서버가 재는 것은 `strlen($delta)` 6MiB 인데, POST 는 그 앞에서 PHP `post_max_size`
|
|
2507
|
+
* (dev 실측 8MiB · **운영 값 미확인**)에 먼저 걸린다 — 넘으면 본문이 통째로 버려지고 `text/html` 경고 덩어리가 200 으로 온다.
|
|
2508
|
+
* 그래서 나누는 자는 검열본 바이트가 아니라 **봉투 바이트**다(실측 부풂 1.016~1.036배 · 깨지는 문턱 1.3333배). */
|
|
2509
|
+
const APPEND_ENV_MAX = 6 * 1024 * 1024 + 512 * 1024;
|
|
2510
|
+
/** 조각 하나가 읽는 원본 구간의 몫 — 검열 출력은 이보다 작거나 비슷하다. 봉투가 상한을 넘으면 줄 경계에서 반씩 더 쪼갠다. */
|
|
2511
|
+
const APPEND_RAW_STEP = 4 * 1024 * 1024;
|
|
2512
|
+
/** 한 바퀴에 덧붙이는 원본의 상한 — 나머지는 다음 바퀴로. 메모리와 임대 묵음 한도를 함께 묶는다. */
|
|
2513
|
+
const APPEND_ROUND_MAX = 32 * 1024 * 1024;
|
|
2514
|
+
/** 흘려 읽기의 걸음(§2.10 「1MB 단위로 흘려 읽고」). */
|
|
2515
|
+
const APPEND_READ_CHUNK = 1024 * 1024;
|
|
2516
|
+
/** 【선언】 FULL 예산 — **진짜 FULL(재기반)** 만 시간당 이만큼(§5 불변식 「전체 재전송으로 폭주하지 않는다」). */
|
|
2517
|
+
const PUSH_FULL_BUDGET = 2;
|
|
2518
|
+
const PUSH_FULL_WINDOW_MS = 3600_000;
|
|
2519
|
+
/**
|
|
2520
|
+
* 【선언】 예산이 세는 FULL 의 까닭 — **재기반**과, **덧붙임을 아는 서버가 이 대화의 덧붙임을 거절한 것**(`append_rejected` —
|
|
2521
|
+
* 거절 응답이 광고를 실은 400·404·405. 바퀴마다 같은 거절을 받고 통째로 내려가는 대화를 묶는다 · C8 반박 fail F1).
|
|
2522
|
+
* 여기 없는 까닭은 구조가 정한 것이라 예산으로 늦춰 봐야 아무도 안 구한다:
|
|
2523
|
+
* `no_cap`(옛 서버 — 그 서버에서는 모든 올리기가 FULL 이다 · 광고 없는 `/append` 거절도 1시간 이 갈래다) · `no_key`(기계 키 없음) ·
|
|
2524
|
+
* `cursor_tail`(Cursor 의 다 쓴 끝 줄 — 덧붙임은 `\n` 경계만 받는다) · `title`(사람이 제목을 고친다 — 입구는 제목을 안 싣는다).
|
|
2525
|
+
* 첫 업로드도 안 센다 — 그 판정은 입구가 한다(`autoEntrance`).
|
|
2526
|
+
*/
|
|
2527
|
+
const PUSH_FULL_REBASE = new Set(['no_base', 'tenant', 'rebake', 'replaced', 'shrunk', 'head_changed', 'seam_changed',
|
|
2528
|
+
'prefix_changed', 'base_moved', 'base_lost', 'append_rejected']);
|
|
2529
|
+
|
|
2530
|
+
/** 버퍼 안의 줄 수(모든 줄이 `\n` 으로 끝난다 — 조각은 줄 경계에서만 끊긴다). */
|
|
2531
|
+
function countLines(buf) {
|
|
2532
|
+
let n = 0;
|
|
2533
|
+
for (let i = buf.indexOf(0x0a); i >= 0; i = buf.indexOf(0x0a, i + 1)) n++;
|
|
2534
|
+
return n;
|
|
2535
|
+
}
|
|
2536
|
+
|
|
2537
|
+
/** 파일 [from, to) 의 줄 수 — 흘려 읽고 **검열은 안 한다**(서버가 뒤처진 따라잡기가 `line_off` 를 되돌릴 때만 쓴다). */
|
|
2538
|
+
function countLinesAt(fd, from, to) {
|
|
2539
|
+
const buf = Buffer.allocUnsafe(APPEND_READ_CHUNK);
|
|
2540
|
+
let n = 0;
|
|
2541
|
+
let at = from;
|
|
2542
|
+
while (at < to) {
|
|
2543
|
+
const got = fs.readSync(fd, buf, 0, Math.min(buf.length, to - at), at);
|
|
2544
|
+
if (got <= 0) break;
|
|
2545
|
+
n += countLines(got === buf.length ? buf : buf.subarray(0, got));
|
|
2546
|
+
at += got;
|
|
2547
|
+
}
|
|
2548
|
+
return n;
|
|
2549
|
+
}
|
|
2550
|
+
|
|
2551
|
+
/** 파일의 [pos, pos+len) 를 읽는다 — 자리 읽기라 fd 의 자리를 안 옮긴다(덧붙임은 전사를 통째로 안 읽는다). */
|
|
2552
|
+
function readAt(fd, pos, len) {
|
|
2553
|
+
const want = Math.max(0, Number(len) || 0);
|
|
2554
|
+
if (want === 0) return Buffer.alloc(0);
|
|
2555
|
+
const b = Buffer.allocUnsafe(want);
|
|
2556
|
+
let got = 0;
|
|
2557
|
+
while (got < want) {
|
|
2558
|
+
const n = fs.readSync(fd, b, got, want - got, pos + got);
|
|
2559
|
+
if (n <= 0) break;
|
|
2560
|
+
got += n;
|
|
2561
|
+
}
|
|
2562
|
+
return got === want ? b : b.subarray(0, got);
|
|
2563
|
+
}
|
|
2564
|
+
|
|
2565
|
+
/** 파일의 **마지막 `\n` 바로 뒤**(§2.1) — 꼬리부터 64KB 씩 거슬러 찾는다(전사를 통째로 안 읽는다). 없으면 0. */
|
|
2566
|
+
function lastNewlineAt(fd, size) {
|
|
2567
|
+
let end = Number(size) || 0;
|
|
2568
|
+
while (end > 0) {
|
|
2569
|
+
const start = Math.max(0, end - PUSH_MAC_WIN);
|
|
2570
|
+
const b = readAt(fd, start, end - start);
|
|
2571
|
+
const i = b.lastIndexOf(0x0a);
|
|
2572
|
+
if (i >= 0) return start + i + 1;
|
|
2573
|
+
end = start;
|
|
2574
|
+
}
|
|
2575
|
+
return 0;
|
|
2576
|
+
}
|
|
2577
|
+
|
|
2578
|
+
/**
|
|
2579
|
+
* [from, to) 를 **1MB 씩 흘려 읽어** 줄 경계 조각으로 넘긴다(§2.10). 조각 하나는 `step` 을 넘어서는 첫 줄 끝에서 끊고,
|
|
2580
|
+
* 통틀어 `cap` 을 넘으면 **거기서 멈춘다**(나머지는 다음 바퀴 — 메모리와 임대 묵음 한도를 함께 묶는다).
|
|
2581
|
+
* 접기(따라잡기)는 `cap` 없이 훑기만 하고, 보내기는 `cap` 안에서 조각을 모은다.
|
|
2582
|
+
* @param {(seg:{from:number,to:number,buf:Buffer}) => void} onSeg
|
|
2583
|
+
*/
|
|
2584
|
+
function appendScan(fd, from, to, step, cap, onSeg) {
|
|
2585
|
+
const chunk = Buffer.allocUnsafe(APPEND_READ_CHUNK);
|
|
2586
|
+
let at = from;
|
|
2587
|
+
let segStart = from;
|
|
2588
|
+
let acc = [];
|
|
2589
|
+
let accLen = 0;
|
|
2590
|
+
while (at < to) {
|
|
2591
|
+
const n = fs.readSync(fd, chunk, 0, Math.min(chunk.length, to - at), at);
|
|
2592
|
+
if (n <= 0) break;
|
|
2593
|
+
let used = 0;
|
|
2594
|
+
let scan = 0;
|
|
2595
|
+
for (;;) {
|
|
2596
|
+
const i = chunk.indexOf(0x0a, scan);
|
|
2597
|
+
if (i < 0 || i >= n) break;
|
|
2598
|
+
const cut = at + i + 1;
|
|
2599
|
+
if (cut - segStart >= step) {
|
|
2600
|
+
acc.push(Buffer.from(chunk.subarray(used, i + 1)));
|
|
2601
|
+
accLen += (i + 1) - used;
|
|
2602
|
+
onSeg({ from: segStart, to: cut, buf: Buffer.concat(acc, accLen) });
|
|
2603
|
+
acc = []; accLen = 0; segStart = cut; used = i + 1;
|
|
2604
|
+
if (cut - from >= cap) return;
|
|
2605
|
+
}
|
|
2606
|
+
scan = i + 1;
|
|
2607
|
+
}
|
|
2608
|
+
if (used < n) { acc.push(Buffer.from(chunk.subarray(used, n))); accLen += n - used; }
|
|
2609
|
+
at += n;
|
|
2610
|
+
}
|
|
2611
|
+
if (accLen > 0) onSeg({ from: segStart, to: segStart + accLen, buf: Buffer.concat(acc, accLen) });
|
|
2612
|
+
}
|
|
2613
|
+
|
|
2614
|
+
/** 조각 하나를 **줄 경계에서** 반으로 쪼갠다 — 봉투가 상한을 넘거나 413 을 받았을 때. 줄 하나뿐이면 null(더 못 쪼갠다). */
|
|
2615
|
+
function splitSeg(seg) {
|
|
2616
|
+
const half = Math.floor(seg.buf.length / 2);
|
|
2617
|
+
let cut = seg.buf.indexOf(0x0a, half);
|
|
2618
|
+
if (cut < 0 || cut + 1 >= seg.buf.length) cut = seg.buf.lastIndexOf(0x0a, Math.max(0, half - 1));
|
|
2619
|
+
if (cut < 0 || cut + 1 >= seg.buf.length) return null;
|
|
2620
|
+
const n = cut + 1;
|
|
2621
|
+
return [{ from: seg.from, to: seg.from + n, buf: seg.buf.subarray(0, n) },
|
|
2622
|
+
{ from: seg.from + n, to: seg.to, buf: seg.buf.subarray(n) }];
|
|
2623
|
+
}
|
|
2624
|
+
|
|
2625
|
+
/**
|
|
2626
|
+
* 변화분 검열 — **줄 번호를 사람 말로 되돌린다**(C6 이 C8 에 넘긴 것). `scrubTranscript` 안의 `no` 는 **이 호출 안의 번호**라,
|
|
2627
|
+
* 덧붙임에서는 「3번째 줄」이 전사의 3번째 줄이 아니다. 보정을 **부르는 쪽에서** 한다 — 그 함수는 `scrub-rev.lock` 의 원천이라
|
|
2628
|
+
* 한 글자만 고쳐도 판(`SCRUB_REV`)을 올려야 하고, 판을 올리면 **이 기계의 모든 대응표가 다음 바퀴에 rebake→FULL** 이 된다.
|
|
2629
|
+
* 줄 번호 하나 때문에 치를 값이 아니다(C8 이 정한 자리).
|
|
2630
|
+
*/
|
|
2631
|
+
function scrubDelta(text, hist, lineBase) {
|
|
2632
|
+
try { return scrubTranscript(text, { delta: true, hist }); } catch (e) {
|
|
2633
|
+
const m = /^(\d+)번째 줄을/.exec(String(e?.message || ''));
|
|
2634
|
+
if (m && Number(lineBase) > 0) throw new Error(String(e.message).replace(/^\d+번째 줄을/, `${Number(m[1]) + Number(lineBase)}번째 줄을`));
|
|
2635
|
+
throw e;
|
|
2636
|
+
}
|
|
2637
|
+
}
|
|
2638
|
+
|
|
2639
|
+
/**
|
|
2640
|
+
* **건너뛴 원본 구간을 `cwd_hist` 에 접는다** — 셈만 하고 검열 출력은 버린다. 전송은 없다(§2.10 따라잡기 · privacy B4).
|
|
2641
|
+
* 이것이 있어야 그 구간에만 나온 자리도 `.genetics-private` 매 바퀴 재검사가 본다 — **좌표를 옮기기 전에** 부른다.
|
|
2642
|
+
* @returns {{hist: object, sc: object, lines: number}}
|
|
2643
|
+
*/
|
|
2644
|
+
function foldHist(fd, from, to, hist, lineBase) {
|
|
2645
|
+
let h = hist && typeof hist === 'object' ? hist : {};
|
|
2646
|
+
let lines = 0;
|
|
2647
|
+
let sc = null;
|
|
2648
|
+
appendScan(fd, from, to, APPEND_RAW_STEP, Infinity, (seg) => {
|
|
2649
|
+
sc = scrubDelta(seg.buf.toString('utf8'), h, lineBase + lines);
|
|
2650
|
+
h = sc.cwd_hist;
|
|
2651
|
+
lines += countLines(seg.buf);
|
|
2652
|
+
});
|
|
2653
|
+
// 접을 것이 없어도 판정 재료는 있어야 한다 — 빈 변화분으로 `cwd`·`cwd_raw` 를 hist 에서 낸다.
|
|
2654
|
+
if (!sc) sc = scrubTranscript('', { delta: true, hist: h });
|
|
2655
|
+
return { hist: h, sc, lines };
|
|
2656
|
+
}
|
|
2657
|
+
|
|
2658
|
+
/**
|
|
2659
|
+
* 이 바퀴는 **덧붙임인가 전체인가**(§2.10 의 의사코드 그대로) — 임대 **안에서만** 부른다(§3.2 「순서가 보증이다」).
|
|
2660
|
+
* 파일은 **자리 읽기**로만 본다(앞 64KB · 이음매 64KB · 기하 시점의 접두) — 전사를 통째로 안 읽는 것이 덧붙임의 요지다.
|
|
2661
|
+
* 갈래마다 FULL 의 **까닭**을 남긴다: 예산(`PUSH_FULL_REBASE`)과 시험이 그 글자로 갈린다.
|
|
2662
|
+
*
|
|
2663
|
+
* @param {number} fd 열린 전사
|
|
2664
|
+
* @param {{size:bigint, ino:bigint, dev:bigint}} st 같은 fd 의 fstat
|
|
2665
|
+
* @param {{tenantId:number, tailDone:boolean, titled?:boolean, onServer:boolean}} o
|
|
2666
|
+
* @returns {{mode:'FULL'|'APPEND'|'ASK', why:string, L:object|null, key:Buffer|null, head_len:number, ck:?{off:number,sha:string}}}
|
|
2667
|
+
*/
|
|
2668
|
+
function planPush(uuid, fd, st, o) {
|
|
2669
|
+
const FULL = (why) => ({ mode: 'FULL', why, L: null, key: null, head_len: 0, ck: null });
|
|
2670
|
+
// ⚠ **사람이 제목을 주면 FULL 이다**(C8 반박 equiv ②). 덧붙임은 메타를 안 바꾼다(agent_session_append.php — UPDATE 에 title 칸이
|
|
2671
|
+
// 없다 · 의도다). 덧붙임으로 보내면 도구는 성공이라 말하고 제목은 **조용히** 그대로였다. 입구는 제목을 안 싣으므로 예산과 무관하다.
|
|
2672
|
+
if (o.titled) return FULL('title');
|
|
2673
|
+
// ⚠ Cursor 의 **다 쓴 끝 줄**은 개행이 없다(C6). 덧붙임은 `\n` 경계만 받으므로 대응표가 있어도 FULL 이다 — 반쪽 줄을 보내면
|
|
2674
|
+
// 서버가 그것을 온전한 줄로 굳히고 좌표는 줄 중간을 가리킨 채 다음 바퀴가 그 줄 꼬리를 또 보낸다(거절이 아니라 조용한 중복).
|
|
2675
|
+
if (o.tailDone) return FULL('cursor_tail');
|
|
2676
|
+
if (!capsAppend()) return FULL('no_cap');
|
|
2677
|
+
const key = pushKey(false);
|
|
2678
|
+
if (!key) return FULL('no_key');
|
|
2679
|
+
const keyId = pushKeyId(key);
|
|
2680
|
+
const L = ledgerRead(uuid);
|
|
2681
|
+
// 대응표가 없거나 이 기계의 것이 아니면 — 서버에 이미 있는 대화면 **물어본다**(base.rev −1 · 쓰기 없음 · race S8).
|
|
2682
|
+
const noBase = () => (o.onServer ? { mode: 'ASK', why: 'no_base', L: null, key, head_len: 0, ck: null } : FULL('no_base'));
|
|
2683
|
+
if (!L || L.v !== 1 || L.key_id !== keyId) return noBase();
|
|
2684
|
+
if (!Number.isInteger(L.rev) || L.rev < 0 || !Number.isInteger(L.bytes) || L.bytes < 0) return noBase();
|
|
2685
|
+
if (!Number.isInteger(L.raw_off) || L.raw_off <= 0) return noBase();
|
|
2686
|
+
if (typeof L.srv_scrub_rev !== 'string' || !CAPS_REV_RE.test(L.srv_scrub_rev)) return noBase();
|
|
2687
|
+
// 개인정보(§5 불변식) — `cwd_hist` 가 없으면 `.genetics-private` 판정 재료가 없다. **올리지 않는다**(따라잡기가 되살린다).
|
|
2688
|
+
if (!L.cwd_hist || typeof L.cwd_hist !== 'object' || Array.isArray(L.cwd_hist)) return noBase();
|
|
2689
|
+
// ⚠ 대응표 열쇠는 uuid 하나인데 서버 행은 (소속처, uuid) 다(C6 이 C8 에 넘긴 반박 ①) — 소속처부터 본다.
|
|
2690
|
+
if (!(Number(o.tenantId) > 0) || Number(L.tenant_id) !== Number(o.tenantId)) return FULL('tenant');
|
|
2691
|
+
if (L.scrub_rev !== SCRUB_REV) return FULL('rebake'); // 판이 둘이다 — 여기는 **클라이언트 판**(C6)
|
|
2692
|
+
if (String(L.ino) !== String(st.ino) || String(L.dev) !== String(st.dev)) return FULL('replaced');
|
|
2693
|
+
if (Number(st.size) < L.raw_off) return FULL('shrunk');
|
|
2694
|
+
// 앞 64KB — 창 길이는 **첫 FULL 이 정한 그 길이**다(위 `head_len` 주석). 옛 대응표(C6)는 `ck_off == 첫 FULL 의 to` 라 거기서 낸다.
|
|
2695
|
+
const headLen = Number.isInteger(L.head_len) && L.head_len > 0
|
|
2696
|
+
? Math.min(L.head_len, L.raw_off)
|
|
2697
|
+
: Math.min(PUSH_MAC_WIN, Number.isInteger(L.ck_off) && L.ck_off > 0 ? L.ck_off : L.raw_off);
|
|
2698
|
+
if (pushMac(key, readAt(fd, 0, headLen)) !== L.head_mac) return FULL('head_changed');
|
|
2699
|
+
// 이음매 64KB — **이것이 「내가 아는 그 자리가 아직 그대로인가」의 탐지기**다. 다르면 보내지 말고 바로 FULL.
|
|
2700
|
+
const wf = Math.max(0, L.raw_off - PUSH_MAC_WIN);
|
|
2701
|
+
if (pushMac(key, readAt(fd, wf, L.raw_off - wf)) !== L.win_mac) return FULL('seam_changed');
|
|
2702
|
+
// 기하 시점 검사(§3.6) — `raw_off ≥ 2·ck_off` 면 [0,ck_off) 를 다시 해시해 대조하고 **같은 읽기로** 새 ck 를 세운다.
|
|
2703
|
+
// 창 밖에서 크기를 유지한 채 제자리에서 다시 쓴 경우를 늦게라도 잡는 자리다. 일생 동안 읽는 총량은 2N 이하다.
|
|
2704
|
+
let ck = null;
|
|
2705
|
+
if (Number.isInteger(L.ck_off) && L.ck_off > 0 && L.raw_off >= 2 * L.ck_off) {
|
|
2706
|
+
const h = crypto.createHash('sha256');
|
|
2707
|
+
const buf = Buffer.allocUnsafe(APPEND_READ_CHUNK);
|
|
2708
|
+
let at = 0;
|
|
2709
|
+
let seen = '';
|
|
2710
|
+
while (at < L.raw_off) {
|
|
2711
|
+
const n = fs.readSync(fd, buf, 0, Math.min(buf.length, L.raw_off - at), at);
|
|
2712
|
+
if (n <= 0) break;
|
|
2713
|
+
if (at < L.ck_off && at + n >= L.ck_off) {
|
|
2714
|
+
h.update(buf.subarray(0, L.ck_off - at));
|
|
2715
|
+
seen = h.copy().digest('hex');
|
|
2716
|
+
h.update(buf.subarray(L.ck_off - at, n));
|
|
2717
|
+
} else h.update(buf.subarray(0, n));
|
|
2718
|
+
at += n;
|
|
2719
|
+
}
|
|
2720
|
+
if (seen !== L.ck_sha) return FULL('prefix_changed');
|
|
2721
|
+
ck = { off: L.raw_off, sha: h.digest('hex') };
|
|
2722
|
+
}
|
|
2723
|
+
return { mode: 'APPEND', why: '', L, key, head_len: headLen, ck };
|
|
2724
|
+
}
|
|
2725
|
+
|
|
2726
|
+
/**
|
|
2727
|
+
* 이 올리기가 가는 소속처의 **숫자 id** — 대응표의 `tenant_id` 와 견줄 값. 입구는 그것을 알고 부른다(`tid`);
|
|
2728
|
+
* 사람의 `push_session` 은 슬러그·이름만 아는 채로 오므로 색인 행에서 **그 이름이 그 행의 소속처일 때만** 낸다.
|
|
2729
|
+
* 모르면 0 이고, 그러면 덧붙이지 않는다(닫는 쪽) — 남의 소속처 행에 이 기계의 좌표를 들이밀지 않는다.
|
|
2730
|
+
*/
|
|
2731
|
+
function pushTenantId(tenantArg, uuid, hint) {
|
|
2732
|
+
if (Number(hint) > 0) return Number(hint);
|
|
2733
|
+
const a = String(tenantArg || '').trim().toLowerCase();
|
|
2734
|
+
if (!a) return 0;
|
|
2735
|
+
const row = indexRead()[String(uuid)];
|
|
2736
|
+
if (!row || typeof row !== 'object' || !(Number(row.tenant_id) > 0)) return 0;
|
|
2737
|
+
return (String(row.tenant_slug || '').toLowerCase() === a || String(row.tenant || '').toLowerCase() === a)
|
|
2738
|
+
? Number(row.tenant_id) : 0;
|
|
2739
|
+
}
|
|
2740
|
+
|
|
2741
|
+
/** [from,to) 의 sha256 — 흘려 읽는다(따라잡기 뒤 기하 점검 자리를 다시 세울 때만 쓴다). */
|
|
2742
|
+
function hashRange(fd, from, to) {
|
|
2743
|
+
const h = crypto.createHash('sha256');
|
|
2744
|
+
const buf = Buffer.allocUnsafe(APPEND_READ_CHUNK);
|
|
2745
|
+
let at = from;
|
|
2746
|
+
while (at < to) {
|
|
2747
|
+
const n = fs.readSync(fd, buf, 0, Math.min(buf.length, to - at), at);
|
|
2748
|
+
if (n <= 0) break;
|
|
2749
|
+
h.update(buf.subarray(0, n));
|
|
2750
|
+
at += n;
|
|
2751
|
+
}
|
|
2752
|
+
return h.digest('hex');
|
|
2753
|
+
}
|
|
2754
|
+
|
|
2755
|
+
/**
|
|
2756
|
+
* **덧붙임 길**(§2.10 · C8) — `[raw_off, EOF)` 를 1MB 로 흘려 읽어 **마지막 `\n` 까지만** 줄 경계 조각으로 나누고,
|
|
2757
|
+
* 조각마다 `POST /api/sessions/append` 를 **차례로** 보낸다. 요청 하나가 커밋 하나다 — 앞 응답의 `rev`·`bytes`·`raw_off` 가
|
|
2758
|
+
* 다음 요청의 기반이라 **병렬로 못 보낸다**(조각 길과 다른 점이 그것이다).
|
|
2759
|
+
*
|
|
2760
|
+
* 순서가 보증이다: ① 조각을 다 **검열**해 `cwd_hist` 를 이어 세고 → ② 그 열쇠 **전부**로 사적 폴더·4096 을 판정하고 →
|
|
2761
|
+
* ③ 그다음에 첫 바이트를 보낸다. 거꾸로 하면 둘째 조각에만 나온 사적 자리가 첫 조각이 나간 뒤에 드러난다.
|
|
2762
|
+
*
|
|
2763
|
+
* ⚠ 제목은 이 길로 안 온다 — 제목이 있으면 `planPush` 이 FULL(`title`)을 고른다(덧붙임은 메타를 안 바꾼다).
|
|
2764
|
+
*
|
|
2765
|
+
* @param {{fd:number, st:object, uuid:string, tenant:string, tenantId:number, dryRun:boolean,
|
|
2766
|
+
* bindOf:'propose'|'skip', auto:?object, hooks:?object, headers:?object, plan:object,
|
|
2767
|
+
* size:number, end:number, isCursorBody:boolean}} a
|
|
2768
|
+
* @returns {Promise<{r:?object, before:number, parts:number, raw_sha:string, plan:object, appended:number, why:string}>}
|
|
2769
|
+
* `r` 이 있으면 보냈다. 없으면 `why` 가 **같은 부름 안에서 FULL 로 내려가는 까닭**이다(예산이 그 글자로 갈린다).
|
|
2770
|
+
*/
|
|
2771
|
+
async function pushAppend(a) {
|
|
2772
|
+
const { fd, st, uuid, tenant, tenantId, dryRun, bindOf, auto, hooks, headers, plan, size, end, isCursorBody } = a;
|
|
2773
|
+
const key = plan.key;
|
|
2774
|
+
const keyId = pushKeyId(key);
|
|
2775
|
+
const L = plan.L;
|
|
2776
|
+
const progressed = () => { try { hooks?.progress?.(); } catch { /* 임대 시계를 못 밀어도 올리기는 계속한다 */ } };
|
|
2777
|
+
const fall = (why) => ({ r: null, why, before: size, parts: 0, raw_sha: '', plan, appended: 0 });
|
|
2778
|
+
const stop = (code, msg) => { throw Object.assign(new Error(msg), { code, status: code === 'session_hidden' ? 409 : 0 }); };
|
|
2779
|
+
|
|
2780
|
+
let headMac = L ? String(L.head_mac) : '';
|
|
2781
|
+
let headLen = plan.head_len;
|
|
2782
|
+
let srvRev = L ? String(L.srv_scrub_rev) : capsScrubRev();
|
|
2783
|
+
let from = L ? L.raw_off : 0;
|
|
2784
|
+
let base = L ? { rev: L.rev, bytes: Number(L.bytes) || 0 } : { rev: -1, bytes: 0 };
|
|
2785
|
+
let winFrom = L ? String(L.win_mac) : '';
|
|
2786
|
+
let lineOff = L && Number.isInteger(L.line_off) ? L.line_off : 0;
|
|
2787
|
+
let ck = plan.ck || (L && Number.isInteger(L.ck_off) && L.ck_off > 0 ? { off: L.ck_off, sha: String(L.ck_sha || '') } : null);
|
|
2788
|
+
let hist = L && L.cwd_hist && typeof L.cwd_hist === 'object' ? { ...L.cwd_hist } : {};
|
|
2789
|
+
let headInfo = L && L.head && typeof L.head === 'object' ? L.head : { id: '', ms: 0 };
|
|
2790
|
+
|
|
2791
|
+
// ⚠ **dry_run 은 대응표를 안 만든다**(C6 의 계약 — FULL 쪽 주석과 같은 규칙). 전에는 대응표 없는 dry_run 의 물음(ASK)이
|
|
2792
|
+
// 서버 좌표를 받아 `push/<uuid>.json` 을 세웠다(C8 반박 fail F2·equiv ⑧·cost ⑤).
|
|
2793
|
+
const ledgerSave = () => (dryRun ? false : ledgerWrite(uuid, {
|
|
2794
|
+
v: 1, uuid, tenant_id: tenantId, key_id: keyId, rev: base.rev, bytes: base.bytes, raw_off: from,
|
|
2795
|
+
head_mac: headMac, head_len: headLen, win_mac: winFrom, ino: String(st.ino), dev: String(st.dev),
|
|
2796
|
+
scrub_rev: SCRUB_REV, srv_scrub_rev: srvRev,
|
|
2797
|
+
ck_off: ck ? ck.off : 0, ck_sha: ck ? ck.sha : '',
|
|
2798
|
+
cwd_hist: hist, line_off: lineOff, head: headInfo,
|
|
2799
|
+
}));
|
|
2800
|
+
|
|
2801
|
+
/**
|
|
2802
|
+
* **따라잡기** — 서버 좌표가 이 기계 키이고 그 자리의 이음매 MAC 이 내 파일과 맞으면 서버 사본은 내 파일의 접두사다.
|
|
2803
|
+
* 그 자리에서 잇는다. **방향을 가리지 않는다**(서버가 앞서든 뒤처지든 서버 좌표에서 시작한다).
|
|
2804
|
+
* ⚠⚠ **좌표를 옮기기 전에 건너뛰는 원본 구간을 읽어 `cwd_hist` 에 접는다**(§5 불변식 「개인정보 판정 재료가 좌표보다
|
|
2805
|
+
* 늦지 않다」 · privacy B4). 안 그러면 그 구간에만 나온 사적 폴더가 판정 없이 통과한다.
|
|
2806
|
+
*/
|
|
2807
|
+
const catchUp = (coords) => {
|
|
2808
|
+
if (!coords || String(coords.raw_key_id || '') !== keyId) return false; // 다른 기계 — MAC 을 겨룰 수 없다
|
|
2809
|
+
const off = Number(coords.raw_off);
|
|
2810
|
+
const rev = Number(coords.rev);
|
|
2811
|
+
if (!Number.isInteger(off) || off < 0 || off > size) return false; // 서버가 내 파일보다 뒤까지 안다 — 못 잇는다
|
|
2812
|
+
if (!Number.isInteger(rev) || rev < 0) return false;
|
|
2813
|
+
const wf = Math.max(0, off - PUSH_MAC_WIN);
|
|
2814
|
+
if (pushMac(key, readAt(fd, wf, off - wf)) !== String(coords.raw_win_mac || '')) return false;
|
|
2815
|
+
if (off > from) { const f0 = foldHist(fd, from, off, hist, lineOff); hist = f0.hist; lineOff += f0.lines; }
|
|
2816
|
+
// 서버가 뒤처진 쪽(off < from)은 접을 것이 없다 — 내 hist 는 이미 그 앞을 다 세었다(넉넉한 쪽으로 남긴다).
|
|
2817
|
+
// ⚠ 다만 `line_off` 는 되돌린다 — [off, from) 을 다시 보내며 또 세면 줄 번호가 두 번 센 값이 된다(C8 반박 fail F3 · 실측 41→62).
|
|
2818
|
+
// 줄 수는 빼기가 정확하다(검열 없이 \n 만 센다). cwd_hist 의 셈은 빼기가 안 되어 넉넉한 쪽으로 남는다(열쇠 집합은 같다).
|
|
2819
|
+
else if (off < from) lineOff = Math.max(0, lineOff - countLinesAt(fd, off, from));
|
|
2820
|
+
from = off;
|
|
2821
|
+
base = { rev, bytes: Number(coords.bytes) || 0 };
|
|
2822
|
+
winFrom = String(coords.raw_win_mac);
|
|
2823
|
+
if (!L) {
|
|
2824
|
+
// 대응표가 없는 따라잡기 — `head_mac` 은 서버가 안 준다. 첫 FULL 의 창 길이를 모르니 `min(64K, raw_off)` 로 **가정**한다.
|
|
2825
|
+
// 응답만 잃은 경우(race S8)에는 그 값이 곧 참이고, 아니면 다음 요청이 base_moved 로 돌아와 FULL 이 바로잡는다.
|
|
2826
|
+
headLen = Math.min(PUSH_MAC_WIN, off);
|
|
2827
|
+
headMac = pushMac(key, readAt(fd, 0, headLen));
|
|
2828
|
+
ck = off > 0 ? { off, sha: hashRange(fd, 0, off) } : null;
|
|
2829
|
+
}
|
|
2830
|
+
return true;
|
|
2831
|
+
};
|
|
2832
|
+
|
|
2833
|
+
/** §5.1 의 표 그대로 — 덧붙임의 거절이 어디로 가는가. **`code` 를 먼저 보고 `status` 를 나중에 본다**(관문 pre 가 413 보다 앞이다). */
|
|
2834
|
+
const triage = (e) => {
|
|
2835
|
+
const status = Number(e?.status) || 0;
|
|
2836
|
+
const code = String(e?.code || '');
|
|
2837
|
+
const why = String(e?.why || '');
|
|
2838
|
+
if (code === 'base_moved' || code === 'behind') return { coords: e?.coords || null };
|
|
2839
|
+
// 가림·걷음은 **멈춘다** — 되살리기는 사람의 전체 PUT 만(§0-5·§3.3). 입구의 붙박이 표와 같은 무게로 옮긴다.
|
|
2840
|
+
if (code === 'no_base') {
|
|
2841
|
+
return why === 'hidden' || why === 'purged'
|
|
2842
|
+
? { sticky: Object.assign(new Error(e.message), { status: 409, code: 'session_hidden' }) }
|
|
2843
|
+
: { fall: 'no_base' };
|
|
2844
|
+
}
|
|
2845
|
+
if (code === 'rebake') return { fall: 'rebake' };
|
|
2846
|
+
if (code === 'base_lost') return { fall: 'base_lost' };
|
|
2847
|
+
if (code === 'delta_too_big' || status === 413) return { split: true };
|
|
2848
|
+
// 200 인데 JSON 이 아니다 — 봉투가 `post_max_size` 를 넘어 PHP 가 본문을 버렸다(운영 값 미확인 · C8 반박 fail F5).
|
|
2849
|
+
// 413 과 같게 줄 경계에서 더 쪼갠다. 줄 하나로도 안 되면 아래 `halve()` 가 FULL 로 내린다(그 바퀴 안에서 끝난다).
|
|
2850
|
+
if (e?.not_json && status === 200) return { split: true };
|
|
2851
|
+
if (status === 400 || status === 404 || status === 405) {
|
|
2852
|
+
// ★ 거절 응답이 **광고를 실었으면** 서버는 덧붙임을 안다 — 이 요청이 거절된 것이다(`bad_coords`·남의 소속처 404).
|
|
2853
|
+
// 광고 캐시는 그대로 두고 이 대화만 FULL 로, **예산이 세는 까닭**으로 내린다(C8 반박 fail F1 — 전에는 캐시를 거짓으로
|
|
2854
|
+
// 덮었지만 곧이어 도는 FULL 의 응답이 광고로 되돌려, 안 세는 FULL 이 90초마다 무기한 되풀이됐다).
|
|
2855
|
+
if (String(e?.caps || '').split(/[\s,]+/).includes('append1')) return { fall: 'append_rejected' };
|
|
2856
|
+
// 광고 없는 거절 — 이 서버(또는 그 앞의 무엇)는 덧붙임을 모른다(§5.1). 1시간 거짓(광고로도 못 덮는다)으로 두고 이번은 FULL.
|
|
2857
|
+
capsAppendGone();
|
|
2858
|
+
return { fall: 'no_cap' };
|
|
2859
|
+
}
|
|
2860
|
+
return { rethrow: true }; // 422·429·403·426·5xx·타임아웃 — 입구의 백오프가 맡는다
|
|
2861
|
+
};
|
|
2862
|
+
|
|
2863
|
+
const cwdFallback = isCursorBody ? relHome(cursorWorkspaceFolder() || '') : relHome(process.cwd());
|
|
2864
|
+
|
|
2865
|
+
/* ── 대응표 없음: **쓰기 없는 물음**으로 서버 좌표를 받는다(base.rev −1 · §2.10 · race S8) ───────────────── */
|
|
2866
|
+
if (plan.mode === 'ASK') {
|
|
2867
|
+
if (!srvRev) return fall('no_base'); // 이 서버의 규칙판을 아직 못 봤다 — FULL 한 번이 알려 준다
|
|
2868
|
+
// 판정 재료가 하나도 없으므로 [0, end) 를 통째로 접는다(셈만 · 전송 없음).
|
|
2869
|
+
const all = foldHist(fd, 0, end, {}, 0);
|
|
2870
|
+
const held0 = hooks?.check ? String(hooks.check(all.sc) || '') : '';
|
|
2871
|
+
if (held0) stop(held0, `자동 올리기를 보류했다(${held0})`);
|
|
2872
|
+
let folder0 = null;
|
|
2873
|
+
if (auto) {
|
|
2874
|
+
const prep = auto.prepare(all.sc);
|
|
2875
|
+
if (prep.code) throw Object.assign(new Error(`자동 올리기를 보류했다(${prep.code})`),
|
|
2876
|
+
{ code: prep.code, status: prep.code === 'place_unreadable' ? 422 : 0 });
|
|
2877
|
+
folder0 = prep.folder;
|
|
2878
|
+
} else if (bindOf === 'propose') folder0 = pushProposeFolder(all.sc, isCursorBody);
|
|
2879
|
+
const Z = '0'.repeat(64);
|
|
2880
|
+
let coords = null;
|
|
2881
|
+
try {
|
|
2882
|
+
await api('POST', '/api/sessions/append', {
|
|
2883
|
+
tenant, uuid, bind: bindOf, origin_cwd: all.sc.cwd || cwdFallback, ...(folder0 ? { folder: folder0 } : {}),
|
|
2884
|
+
base: { rev: -1, bytes: 0 },
|
|
2885
|
+
raw: { key_id: keyId, from: 0, to: 0, head_mac: Z, win_mac_from: Z, win_mac_to: Z },
|
|
2886
|
+
scrub_rev: srvRev, delta: '',
|
|
2887
|
+
}, { headers, timeout_ms: apiTimeoutFor(0) });
|
|
2888
|
+
return fall('no_base'); // 200 은 올 수 없다(rev −1 은 언제나 base_moved) — 모르는 서버다
|
|
2889
|
+
} catch (e) {
|
|
2890
|
+
const t = triage(e);
|
|
2891
|
+
if (t.sticky) throw t.sticky;
|
|
2892
|
+
if (t.rethrow) throw e;
|
|
2893
|
+
if (t.coords === undefined || t.coords === null) return fall(t.fall || 'no_base');
|
|
2894
|
+
coords = t.coords;
|
|
2895
|
+
}
|
|
2896
|
+
if (!catchUp(coords)) return fall('no_base');
|
|
2897
|
+
ledgerSave(); // **전송 없이** 좌표를 되살렸다(S8)
|
|
2898
|
+
}
|
|
2899
|
+
|
|
2900
|
+
/* ── 조각을 모으고 보낸다 ──────────────────────────────────────────────────────────────────── */
|
|
2901
|
+
let moved = plan.mode === 'ASK';
|
|
2902
|
+
let caught = 0;
|
|
2903
|
+
for (;;) {
|
|
2904
|
+
const items = [];
|
|
2905
|
+
{
|
|
2906
|
+
let h = hist;
|
|
2907
|
+
let lb = lineOff;
|
|
2908
|
+
appendScan(fd, from, end, APPEND_RAW_STEP, APPEND_ROUND_MAX, (seg) => {
|
|
2909
|
+
const sc = scrubDelta(seg.buf.toString('utf8'), h, lb);
|
|
2910
|
+
items.push({ seg, sc, hist0: h, lb, lines: countLines(seg.buf) });
|
|
2911
|
+
h = sc.cwd_hist;
|
|
2912
|
+
lb += countLines(seg.buf);
|
|
2913
|
+
});
|
|
2914
|
+
}
|
|
2915
|
+
// 판정은 **이번 구간 전부를 접은 뒤** 한 번(§3.3 「cwd_hist 열쇠 전부로 매 바퀴」). 조각이 없으면 지금까지 접은 hist 로.
|
|
2916
|
+
const lastSc = items.length ? items[items.length - 1].sc : scrubTranscript('', { delta: true, hist });
|
|
2917
|
+
const held = hooks?.check ? String(hooks.check(lastSc) || '') : '';
|
|
2918
|
+
if (held) stop(held, `자동 올리기를 보류했다(${held})`);
|
|
2919
|
+
let folder = null;
|
|
2920
|
+
if (auto) {
|
|
2921
|
+
/*
|
|
2922
|
+
* ★ **32MB 는 「한 번 전송량」이 아니라 「서버 사본 한 벌의 크기」다**(§3.1 · C7 이 C8 로 넘긴 것).
|
|
2923
|
+
* 길이 FULL 뿐이던 때는 보내는 검열본 전체가 곧 그 사본이라 같은 값이었다. 덧붙임에서는 `row.bytes + 조각` 이다.
|
|
2924
|
+
*/
|
|
2925
|
+
const add = items.reduce((n, it) => n + Buffer.byteLength(it.sc.jsonl, 'utf8'), 0);
|
|
2926
|
+
const prep = base.bytes + add > AUTO_BODY_MAX ? { code: 'too_big' } : auto.prepare(lastSc);
|
|
2927
|
+
if (prep.code) throw Object.assign(new Error(`자동 올리기를 보류했다(${prep.code})`),
|
|
2928
|
+
{ code: prep.code, status: prep.code === 'place_unreadable' ? 422 : 0 });
|
|
2929
|
+
folder = prep.folder;
|
|
2930
|
+
} else if (bindOf === 'propose') folder = pushProposeFolder(lastSc, isCursorBody);
|
|
2931
|
+
|
|
2932
|
+
if (!items.length) {
|
|
2933
|
+
// 보낼 원본 구간이 없다 — 파일이 안 자랐거나, 따라잡기로 좌표만 옮겼다. **실패가 아니다**(입구가 간격만 비춘다).
|
|
2934
|
+
// ★★ 따라잡았으면 **그 좌표를 적고** 끝낸다(C8 반박 equiv ①). 전에는 보내는 고리 안의 따라잡기(409 base_moved)가 옮긴 좌표를
|
|
2935
|
+
// 안 적어, 다음 바퀴가 옛 좌표에서 같은 구간을 다시 검열·전송하고 409 를 받고 또 `caught_up` — 끝난 대화에서 90초마다
|
|
2936
|
+
// 무기한이었다(`caught_up` 은 실패가 아니라 백오프도 안 걸리고, 예산은 FULL 만 센다).
|
|
2937
|
+
if (moved) ledgerSave();
|
|
2938
|
+
throw Object.assign(new Error(moved
|
|
2939
|
+
? '서버 좌표를 따라잡아 대응표만 세웠다(보낼 줄 없음)'
|
|
2940
|
+
: '덧붙일 줄이 없다(서버가 이미 이 대화의 끝까지 가지고 있다)'), { code: 'caught_up', status: 0, moved });
|
|
2941
|
+
}
|
|
2942
|
+
|
|
2943
|
+
const queue = items.slice();
|
|
2944
|
+
let last = null;
|
|
2945
|
+
let parts = 0;
|
|
2946
|
+
let appended = 0;
|
|
2947
|
+
let restart = false;
|
|
2948
|
+
while (queue.length) {
|
|
2949
|
+
const it = queue[0];
|
|
2950
|
+
const segTo = it.seg.to;
|
|
2951
|
+
const wf = Math.max(0, segTo - PUSH_MAC_WIN);
|
|
2952
|
+
const winTo = pushMac(key, readAt(fd, wf, segTo - wf));
|
|
2953
|
+
const env = {
|
|
2954
|
+
tenant, uuid, bind: bindOf,
|
|
2955
|
+
origin_cwd: it.sc.cwd || cwdFallback, ...(folder ? { folder } : {}),
|
|
2956
|
+
base: { rev: base.rev, bytes: base.bytes },
|
|
2957
|
+
raw: { key_id: keyId, from: it.seg.from, to: segTo, head_mac: headMac, win_mac_from: winFrom, win_mac_to: winTo },
|
|
2958
|
+
scrub_rev: srvRev, delta: it.sc.jsonl,
|
|
2959
|
+
...(dryRun ? { dry_run: true } : {}),
|
|
2960
|
+
};
|
|
2961
|
+
/*
|
|
2962
|
+
* ★ **나누는 자는 검열본이 아니라 봉투 바이트다**(C4 가 C6→C8 로 넘긴 줄). 서버는 `strlen($delta)` 6MiB 를 재지만,
|
|
2963
|
+
* POST 는 그 앞에서 `post_max_size` 에 먼저 걸린다 — 넘으면 200·`text/html`·머리 0개가 와서 **조용히 아무것도
|
|
2964
|
+
* 안 올라간다**. 봉투를 먼저 재고, 그래도 413 이 오면 줄 경계에서 반으로 쪼개 앞쪽부터 다시 보낸다.
|
|
2965
|
+
*/
|
|
2966
|
+
const envBytes = Buffer.byteLength(JSON.stringify(env), 'utf8');
|
|
2967
|
+
const halve = () => {
|
|
2968
|
+
const two = splitSeg(it.seg);
|
|
2969
|
+
if (!two) return false;
|
|
2970
|
+
queue.shift();
|
|
2971
|
+
const A = { seg: two[0], sc: scrubDelta(two[0].buf.toString('utf8'), it.hist0, it.lb), hist0: it.hist0, lb: it.lb, lines: countLines(two[0].buf) };
|
|
2972
|
+
const B = { seg: two[1], sc: scrubDelta(two[1].buf.toString('utf8'), A.sc.cwd_hist, it.lb + A.lines), hist0: A.sc.cwd_hist, lb: it.lb + A.lines, lines: countLines(two[1].buf) };
|
|
2973
|
+
queue.unshift(A, B);
|
|
2974
|
+
return true;
|
|
2975
|
+
};
|
|
2976
|
+
if (envBytes > APPEND_ENV_MAX) {
|
|
2977
|
+
if (halve()) continue;
|
|
2978
|
+
return fall('rebake'); // 줄 하나가 상한을 넘는다 — 덧붙임으로는 못 보낸다(FULL 의 조각 길로)
|
|
2979
|
+
}
|
|
2980
|
+
let r;
|
|
2981
|
+
try {
|
|
2982
|
+
r = await api('POST', '/api/sessions/append', env, { headers, timeout_ms: apiTimeoutFor(envBytes) });
|
|
2983
|
+
} catch (e) {
|
|
2984
|
+
const t = triage(e);
|
|
2985
|
+
if (t.split) { if (halve()) continue; return fall('rebake'); }
|
|
2986
|
+
if (t.sticky) throw t.sticky;
|
|
2987
|
+
if (t.fall) return fall(t.fall);
|
|
2988
|
+
if (t.rethrow) throw e;
|
|
2989
|
+
// base_moved / behind — 따라잡기는 **한 바퀴에 한 번**. 또 어긋나면 FULL 로 재기반한다.
|
|
2990
|
+
if (caught >= 1 || !catchUp(t.coords)) return fall('base_moved');
|
|
2991
|
+
caught++;
|
|
2992
|
+
moved = true;
|
|
2993
|
+
restart = true;
|
|
2994
|
+
break;
|
|
2995
|
+
}
|
|
2996
|
+
progressed();
|
|
2997
|
+
parts++;
|
|
2998
|
+
appended += Buffer.byteLength(it.sc.jsonl, 'utf8');
|
|
2999
|
+
last = r;
|
|
3000
|
+
queue.shift();
|
|
3001
|
+
if (dryRun) {
|
|
3002
|
+
/*
|
|
3003
|
+
* ★ **덧붙임 dry_run 도 같은 길을 간다** — 다만 조각 **하나**까지다. 서버가 행을 안 쓰므로 기반이 안 움직이고,
|
|
3004
|
+
* 둘째 조각의 `raw.from` 은 행의 `raw_off` 와 달라 409 base_moved 가 된다. 대응표·색인도 안 건드린다(FULL 과 같은 규칙).
|
|
3005
|
+
*/
|
|
3006
|
+
return { r, before: size, parts, raw_sha: '', plan, appended, why: '' };
|
|
3007
|
+
}
|
|
3008
|
+
// 서버가 그 좌표를 세웠다고 답한 때만 대응표를 옮긴다 — `raw_win_mac === 보낸 값` 이 이 키인지도 함께 잰다(응답에 key_id 가 없다).
|
|
3009
|
+
if (!Number.isInteger(r?.rev) || r.raw_off !== segTo || r.raw_win_mac !== winTo) {
|
|
3010
|
+
ledgerDrop(uuid);
|
|
3011
|
+
return fall('no_base');
|
|
3012
|
+
}
|
|
3013
|
+
base = { rev: r.rev, bytes: Number.isInteger(r.bytes) ? r.bytes : base.bytes };
|
|
3014
|
+
from = segTo;
|
|
3015
|
+
winFrom = winTo;
|
|
3016
|
+
hist = it.sc.cwd_hist;
|
|
3017
|
+
lineOff = it.lb + it.lines;
|
|
3018
|
+
if (typeof r.scrub_rev === 'string' && CAPS_REV_RE.test(r.scrub_rev)) srvRev = r.scrub_rev;
|
|
3019
|
+
headInfo = { id: String(r.head_id || ''), ms: Number(r.head_ms) || 0 };
|
|
3020
|
+
ledgerSave(); // **성공마다** 옮긴다 — 도중에 죽어도 다음 바퀴가 여기서 잇는다
|
|
3021
|
+
}
|
|
3022
|
+
if (restart) continue;
|
|
3023
|
+
pushIndexNote(uuid, tenant, last, ''); // `raw_sha` 는 FULL 에서만(§2.10) — 덧붙임은 원본 전체를 안 읽는다
|
|
3024
|
+
return { r: last, before: size, parts, raw_sha: '', plan, appended, why: '' };
|
|
3025
|
+
}
|
|
3026
|
+
}
|
|
3027
|
+
|
|
2018
3028
|
/**
|
|
2019
3029
|
* 이 기계의 대화 하나를 검열해서 올린다.
|
|
2020
3030
|
*
|
|
@@ -2033,18 +3043,86 @@ function toolsStamp() {
|
|
|
2033
3043
|
return t;
|
|
2034
3044
|
}
|
|
2035
3045
|
|
|
3046
|
+
/** 개행 없는 끝 줄이 **다 쓴 줄**인가 — 온전한 JSON 객체로 읽히면 그렇다(반쪽 줄은 객체로 안 읽힌다). 공백뿐이면 아니다. */
|
|
3047
|
+
function cursorTailDone(tail) {
|
|
3048
|
+
const s = tail.toString('utf8');
|
|
3049
|
+
if (!s.trim()) return false;
|
|
3050
|
+
try { const r = JSON.parse(s); return !!r && typeof r === 'object' && !Array.isArray(r); } catch { return false; }
|
|
3051
|
+
}
|
|
3052
|
+
|
|
2036
3053
|
/**
|
|
2037
3054
|
* @param {'propose'|'skip'} [bind] `'propose'` 면 이 대화의 자리 폴더를 그 소속처에 **제안**한다(§4.2 —
|
|
2038
3055
|
* 확정·자동 올리기는 포털에서 사람이 한다). 사람이 부른 push_session 만 제안하고, 심박의 갱신은 `'skip'`.
|
|
3056
|
+
* @param {null|{check?: (sc: object) => string, progress?: () => void}} [hooks] 입구(C7)가 거는 손잡이 둘 —
|
|
3057
|
+
* `check` 는 검열 뒤·보내기 전에 한 번 불러 코드('')가 아니면 **안 보낸다**(옛 길의 사적 폴더·4096 — 봉투는 안 바꾼다:
|
|
3058
|
+
* `auto` 와 달리 머리·folder 를 안 싣는다) · `progress` 는 요청 하나(조각 하나)가 끝날 때마다 — 임대의 묵음 시계를 민다(§3.2).
|
|
2039
3059
|
*/
|
|
2040
|
-
async function pushSession(tenant, uuid, title, dryRun, bind = 'skip', auto = null) {
|
|
3060
|
+
async function pushSession(tenant, uuid, title, dryRun, bind = 'skip', auto = null, hooks = null, tenantIdHint = 0) {
|
|
2041
3061
|
const f = findTranscript(uuid);
|
|
2042
3062
|
if (!f) throw new Error(`이 기계에 그 세션이 없습니다: ${uuid}`);
|
|
2043
|
-
//
|
|
2044
|
-
|
|
2045
|
-
const
|
|
2046
|
-
|
|
3063
|
+
// 한 fd 로 stat 과 읽기를 한다 — 대응표의 ino·dev 가 읽은 그 파일의 것이어야 한다(§2.11).
|
|
3064
|
+
// ★ **덧붙임은 이 fd 를 자리 읽기로만 쓴다**(C8) — 전사를 통째로 안 읽는 것이 이 커밋의 요지다.
|
|
3065
|
+
const fd = fs.openSync(f, 'r');
|
|
3066
|
+
try { return await pushSessionFd(fd, f, tenant, uuid, title, dryRun, bind, auto, hooks, tenantIdHint); }
|
|
3067
|
+
finally { fs.closeSync(fd); }
|
|
3068
|
+
}
|
|
3069
|
+
|
|
3070
|
+
async function pushSessionFd(fd, f, tenant, uuid, title, dryRun, bind, auto, hooks, tenantIdHint) {
|
|
3071
|
+
const st = fs.fstatSync(fd, { bigint: true });
|
|
3072
|
+
const size = Number(st.size);
|
|
3073
|
+
const isCursorBody = f.includes(`${path.sep}.cursor${path.sep}projects${path.sep}`);
|
|
3074
|
+
// 끝 줄 판정도 **꼬리만** 읽는다(§2.1) — 마지막 `\n` 바로 뒤가 좌표 `raw_off` 다.
|
|
3075
|
+
const toEnd = lastNewlineAt(fd, size);
|
|
3076
|
+
const tailDone = isCursorBody && toEnd < size && cursorTailDone(readAt(fd, toEnd, size - toEnd));
|
|
3077
|
+
const tid = pushTenantId(tenant, uuid, tenantIdHint);
|
|
3078
|
+
const bindOf = bind === 'propose' ? 'propose' : 'skip';
|
|
3079
|
+
let headers;
|
|
3080
|
+
if (!auto && bindOf === 'propose') headers = { 'X-Genetics-Push': 'human' };
|
|
3081
|
+
if (auto) {
|
|
3082
|
+
headers = { 'X-Genetics-Binding': String(auto.binding_id), 'X-Genetics-Device': auto.device,
|
|
3083
|
+
'X-Genetics-Client': `genetics-mcp/${VERSION}` };
|
|
3084
|
+
}
|
|
3085
|
+
/*
|
|
3086
|
+
* ── 길을 고른다(§2.10 · §3.2) ──────────────────────────────────────────────────────────────
|
|
3087
|
+
* `planPush` 은 자리 읽기만 한다. 갈래마다 FULL 의 **까닭**이 남고, 예산(`hooks.plan`)이 그 글자로 갈린다.
|
|
3088
|
+
* 덧붙임이 섰다가 서버가 「기반이 다르다」고 하면 **따라잡기**로 그 자리에서 잇고, 그것도 안 되면 같은 부름 안에서 FULL 로 내려온다.
|
|
3089
|
+
*/
|
|
3090
|
+
const titled = typeof title === 'string' && title.trim() !== '';
|
|
3091
|
+
let plan = planPush(uuid, fd, st, { tenantId: tid, tailDone, titled, onServer: autoRowOnServer(indexRead()[uuid]) });
|
|
3092
|
+
if (plan.mode !== 'FULL') {
|
|
3093
|
+
const got = await pushAppend({ fd, st, uuid, tenant, tenantId: tid, dryRun, bindOf, auto, hooks, headers,
|
|
3094
|
+
plan, size, end: toEnd, isCursorBody });
|
|
3095
|
+
if (got.r) return got;
|
|
3096
|
+
plan = { ...plan, mode: 'FULL', why: got.why };
|
|
3097
|
+
}
|
|
3098
|
+
const held0 = hooks?.plan ? String(hooks.plan(plan) || '') : ''; // FULL 예산(§3.2) — 입구만 건다(사람의 push 는 안 센다)
|
|
3099
|
+
if (held0) throw Object.assign(new Error(`자동 올리기를 보류했다(${held0})`), { code: held0, status: 0 });
|
|
3100
|
+
const bytes = readAt(fd, 0, size);
|
|
3101
|
+
/*
|
|
3102
|
+
* ★ **마지막 `\n` 까지만 검열해 보낸다**(§2.1 · C6). 좌표 `raw_off` 가 늘 그 자리이고, 본문과 좌표가 같은 구간이어야
|
|
3103
|
+
* 서버 행의 끝이 곧 이 좌표다. 쓰는 중인 반쪽 끝 줄은 다음 바퀴로 넘긴다(전에는 그 줄에서 JSON 이 깨져 통째로 실패했다).
|
|
3104
|
+
* `raw_sha` 는 뜻 그대로 **원본 전체**의 sha 다(후보 쪽 `rawShaOf()` 와 같은 범위 — 사본 판정이 갈리지 않게).
|
|
3105
|
+
* 접두 [0,to) 의 sha(`ck_sha`)는 같은 해시 상태를 복사해 한 번 읽기로 낸다 — 대응표에만 적고 서버에는 안 보낸다.
|
|
3106
|
+
*/
|
|
3107
|
+
const to = bytes.lastIndexOf(0x0a) + 1;
|
|
3108
|
+
const hasher = crypto.createHash('sha256').update(bytes.subarray(0, to));
|
|
3109
|
+
const ckSha = hasher.copy().digest('hex');
|
|
3110
|
+
const rawSha = hasher.update(bytes.subarray(to)).digest('hex');
|
|
3111
|
+
/*
|
|
3112
|
+
* ★ **Cursor 의 다 쓴 끝 줄은 싣는다 — 좌표 없이**(C6 반박 equiv). 위 끊기 규칙은 Claude Code 전사의 전제(모든 줄이 `\n` 으로
|
|
3113
|
+
* 끝난다 — 이 기계 148/148)다. Cursor 는 **다 끝난 대화**인데도 마지막 줄에 개행이 없는 것이 있다(173개 중 3개, 끝 줄은 전부
|
|
3114
|
+
* 온전한 JSON · assistant 답). 그 줄을 넘기면 파일이 안 자라는 한 영영 안 올라간다 — 색인 raw_sha 는 원본 전체라 「이미 올렸다」로
|
|
3115
|
+
* 보고, 사람의 push_session 도 한 줄 빠진 채 성공한다(C6 전에는 올라갔다 — 퇴행).
|
|
3116
|
+
* 그래서 Cursor 전사이고 끝 줄이 **온전한 JSON 객체**면 [0,끝) 을 검열해 보내되 **좌표는 안 싣는다**: 좌표 `raw_off` 는 늘 마지막
|
|
3117
|
+
* `\n` 바로 뒤여야 하고(§2.1), 본문과 좌표가 다른 구간이면 서버 행의 끝이 좌표가 아니다. 대응표도 지워진다(좌표 없는 FULL) —
|
|
3118
|
+
* 그러면 다음 갱신(C8)은 대응표가 없어 FULL 로 간다. 반쪽 끝 줄(파싱 안 됨)은 Cursor 여도 전처럼 다음 바퀴로 넘긴다.
|
|
3119
|
+
* Claude Code 전사는 그대로다 — 다 쓴 줄이 개행 직전에 잠깐 보이는 창에서 그 줄을 실으면 머리 끊기 규칙(transcriptHead)과 갈린다.
|
|
3120
|
+
*/
|
|
3121
|
+
const sc = scrubTranscript(bytes.subarray(0, tailDone ? bytes.length : to).toString('utf8'));
|
|
2047
3122
|
const before = bytes.length;
|
|
3123
|
+
const held = hooks?.check ? String(hooks.check(sc) || '') : '';
|
|
3124
|
+
if (held) throw Object.assign(new Error(`자동 올리기를 보류했다(${held})`), { code: held, status: 0 });
|
|
3125
|
+
const progressed = () => { try { hooks?.progress?.(); } catch { /* 임대 시계를 못 밀어도 올리기는 계속한다 */ } };
|
|
2048
3126
|
/*
|
|
2049
3127
|
* ★ **제목을 안 정했으면 키를 아예 뺀다.** 예전에는 `title: title || ''` 로 빈 문자열을
|
|
2050
3128
|
* **명시해서** 보냈고 서버가 그것으로 기존 제목을 덮었다. 자동 올리기는 제목 없이 부르므로
|
|
@@ -2072,7 +3150,6 @@ async function pushSession(tenant, uuid, title, dryRun, bind = 'skip', auto = nu
|
|
|
2072
3150
|
* 같은 창의 Cursor MCP 면 워크스페이스 폴더가 진짜 자리다(cursorWorkspaceFolder) —
|
|
2073
3151
|
* 그것도 모르면 **빈 값**으로 둔다. 모르는 것은 모른다고 적는다(자리 판정은 빈 자리를 안다).
|
|
2074
3152
|
*/
|
|
2075
|
-
const isCursorBody = f.includes(`${path.sep}.cursor${path.sep}projects${path.sep}`);
|
|
2076
3153
|
const body = {
|
|
2077
3154
|
tenant, uuid,
|
|
2078
3155
|
origin_cwd: sc.cwd || (isCursorBody ? relHome(cursorWorkspaceFolder() || '') : relHome(process.cwd())),
|
|
@@ -2086,10 +3163,9 @@ async function pushSession(tenant, uuid, title, dryRun, bind = 'skip', auto = nu
|
|
|
2086
3163
|
* 들어 있을 수 있어 원문을 안 보낸다. 자리는 origin_cwd 와 같은 차례로 고른다(본문 최빈 cwd →
|
|
2087
3164
|
* Cursor 창 폴더 → 이 프로세스). 집·임시 폴더이거나 identity 를 못 만들면 folder 를 안 싣는다.
|
|
2088
3165
|
*/
|
|
2089
|
-
body.bind =
|
|
3166
|
+
body.bind = bindOf;
|
|
2090
3167
|
if (body.bind === 'propose') {
|
|
2091
|
-
const
|
|
2092
|
-
const folder = place ? folderForPush(folderOf(place)) : null;
|
|
3168
|
+
const folder = pushProposeFolder(sc, isCursorBody);
|
|
2093
3169
|
if (folder) body.folder = folder;
|
|
2094
3170
|
}
|
|
2095
3171
|
/*
|
|
@@ -2104,17 +3180,15 @@ async function pushSession(tenant, uuid, title, dryRun, bind = 'skip', auto = nu
|
|
|
2104
3180
|
* 하나도 없었다 — 대리 표지가 바뀌는 날 사람의 push_session 이 통째로 막힌다.
|
|
2105
3181
|
* 사람의 올리기는 `push_session` 도구 하나뿐이고 그 갈래만 `bind:'propose'` 로 온다(심박의 갱신은 `'skip'`).
|
|
2106
3182
|
*/
|
|
2107
|
-
|
|
2108
|
-
if (!auto && body.bind === 'propose') headers = { 'X-Genetics-Push': 'human' };
|
|
3183
|
+
// 머리 셋은 길을 고르기 전에 이미 섰다(이 함수 머리) — 덧붙임도 **같은 머리**로 간다.
|
|
2109
3184
|
if (auto) {
|
|
3185
|
+
// 32MB 의 뜻은 **서버 사본 한 벌의 크기**다(§3.1). FULL 은 보내는 검열본 전체가 곧 그 사본이라 같은 값을 잰다.
|
|
2110
3186
|
const prep = Buffer.byteLength(sc.jsonl, 'utf8') > AUTO_BODY_MAX ? { code: 'too_big' } : auto.prepare(sc);
|
|
2111
3187
|
if (prep.code) {
|
|
2112
3188
|
throw Object.assign(new Error(`자동 올리기를 보류했다(${prep.code})`),
|
|
2113
3189
|
{ code: prep.code, status: prep.code === 'place_unreadable' ? 422 : 0 }); // 서버가 같은 이유로 내는 422 와 같은 무게
|
|
2114
3190
|
}
|
|
2115
3191
|
body.folder = prep.folder;
|
|
2116
|
-
headers = { 'X-Genetics-Binding': String(auto.binding_id), 'X-Genetics-Device': auto.device,
|
|
2117
|
-
'X-Genetics-Client': `genetics-mcp/${VERSION}` };
|
|
2118
3192
|
}
|
|
2119
3193
|
|
|
2120
3194
|
/*
|
|
@@ -2132,13 +3206,51 @@ async function pushSession(tenant, uuid, title, dryRun, bind = 'skip', auto = nu
|
|
|
2132
3206
|
* 검열 뒤 43.9MB). 「먼저 dry_run」이 권장 절차라 사실상 올릴 길이 없었다. 서버가 조각
|
|
2133
3207
|
* dry_run 을 받는다(조각마다 세고 파일만 안 만든다). `body.dry_run` 이 조각마다 실린다.
|
|
2134
3208
|
*/
|
|
3209
|
+
/*
|
|
3210
|
+
* ── 좌표(§2.9 · C6) — FULL 이 `raw:{key_id,to,head_mac,win_mac_to}`·`scrub_rev`·`base.rev`(대응표가 있으면)를 싣는다 ──
|
|
3211
|
+
* 조각 길이면 **마지막 조각에만** 싣는다(서버는 조각마다 모양을 재고 마지막 조각의 것만 쓴다 — 앞에 실을 까닭이 없다).
|
|
3212
|
+
* 옛 서버는 모르는 칸을 무시하고 전체 교체를 한다 — 뜻이 안 틀어진다. 키가 없으면 좌표 없이 옛 봉투 그대로다.
|
|
3213
|
+
* ⚠ **dry_run 은 키도 대응표도 안 만든다** — 서버가 행을 안 쓰는 시험 실행이 좌표 캐시를 세우면 다음 판정이 거짓 기반을 믿는다.
|
|
3214
|
+
*/
|
|
3215
|
+
let coords = null;
|
|
3216
|
+
let coordEnv = {};
|
|
3217
|
+
if (!dryRun && !tailDone) { // Cursor 의 다 쓴 끝 줄을 실은 FULL 은 좌표 없이(위 tailDone)
|
|
3218
|
+
const key = pushKey();
|
|
3219
|
+
if (key) {
|
|
3220
|
+
coords = pushCoords(key, bytes, to);
|
|
3221
|
+
const L = ledgerRead(uuid);
|
|
3222
|
+
coordEnv = { raw: coords, scrub_rev: SCRUB_REV,
|
|
3223
|
+
...(L && L.key_id === coords.key_id && Number.isInteger(L.rev) && L.rev >= 0 ? { base: { rev: L.rev } } : {}) };
|
|
3224
|
+
}
|
|
3225
|
+
}
|
|
3226
|
+
const ledgerCtx = { ino: String(st.ino), dev: String(st.dev), ck_sha: ckSha, cwd_hist: sc.cwd_hist,
|
|
3227
|
+
line_off: countLines(bytes.subarray(0, to)) };
|
|
3228
|
+
|
|
2135
3229
|
const parts = splitAtLines(sc.jsonl, PUSH_PART_MAX);
|
|
3230
|
+
/*
|
|
3231
|
+
* ★ **FULL 도 봉투 바이트로 잰다**(C8 반박 cost①). 위 나누기는 검열본 6MiB 를 재는데, 보내는 것은 그것을 한 번 더 JSON 으로 싼
|
|
3232
|
+
* 봉투다 — 따옴표·역빗금이 많은 줄은 부푼다(이 기계의 전사 25개 실측 최악 1.0983배 = 6.589MiB, 한 줄 최악 1.54배). 덧붙임은
|
|
3233
|
+
* `APPEND_ENV_MAX` 를 지키는데 FULL 만 넘으면, `post_max_size` 가 그 사이에 있을 때 **FULL 만** 200·`text/html` 로 조용히 죽는다
|
|
3234
|
+
* — 배포 직후·재기반·옛 서버가 전부 FULL 로 온다. 그래서 봉투가 상한을 넘는 조각은 줄 경계에서 반씩 더 쪼갠다.
|
|
3235
|
+
* 조각 번호·upload_id·head_ms 칸은 **가장 긴 모양**으로 채워 잰다(한 조각 봉투는 그보다 작다 — 넉넉한 쪽).
|
|
3236
|
+
* 줄 하나가 넘으면 더 못 쪼갠다 — 그대로 보내고 서버의 답(413)에 맡긴다.
|
|
3237
|
+
*/
|
|
3238
|
+
const envOf = (jsonl) => Buffer.byteLength(JSON.stringify({ ...body, ...coordEnv, jsonl,
|
|
3239
|
+
upload_id: 'f'.repeat(24), part: 99999, parts: 99999, ...(auto ? { head_ms: 9_999_999_999_999 } : {}) }), 'utf8');
|
|
3240
|
+
for (let i = 0; i < parts.length;) {
|
|
3241
|
+
if (envOf(parts[i]) <= APPEND_ENV_MAX) { i++; continue; }
|
|
3242
|
+
const two = splitAtLines(parts[i], Math.max(1, Math.floor(Buffer.byteLength(parts[i], 'utf8') / 2)));
|
|
3243
|
+
if (two.length < 2) { i++; continue; }
|
|
3244
|
+
parts.splice(i, 1, ...two);
|
|
3245
|
+
}
|
|
2136
3246
|
if (parts.length === 1) {
|
|
2137
|
-
|
|
2138
|
-
|
|
2139
|
-
|
|
2140
|
-
|
|
2141
|
-
|
|
3247
|
+
// 상한은 **보내는 바이트에 맞춘다**(호환 1-B) — 6MiB 를 120초로 못 올리는 링크가 실재한다.
|
|
3248
|
+
const r = await api('PUT', '/api/sessions', { ...body, ...coordEnv },
|
|
3249
|
+
{ headers, timeout_ms: apiTimeoutFor(Buffer.byteLength(parts[0], 'utf8')) });
|
|
3250
|
+
progressed();
|
|
3251
|
+
if (!dryRun) ledgerAfterFull(uuid, r, coords, ledgerCtx);
|
|
3252
|
+
if (!dryRun) pushIndexNote(uuid, tenant, r, rawSha);
|
|
3253
|
+
return { r, before, parts: 1, raw_sha: rawSha, plan, appended: 0 };
|
|
2142
3254
|
}
|
|
2143
3255
|
|
|
2144
3256
|
/*
|
|
@@ -2167,14 +3279,36 @@ async function pushSession(tenant, uuid, title, dryRun, bind = 'skip', auto = nu
|
|
|
2167
3279
|
...body, jsonl: parts[i],
|
|
2168
3280
|
upload_id: uploadId, part: i + 1, parts: parts.length,
|
|
2169
3281
|
...(auto ? { head_ms: autoHeadMs } : {}), // 관문 11 은 조각마다 선다 — 첫 조각에만 실으면 둘째부터 409 다
|
|
2170
|
-
|
|
3282
|
+
...(i === parts.length - 1 ? coordEnv : {}), // 좌표는 마지막 조각에만(§2.9)
|
|
3283
|
+
}, { headers, timeout_ms: apiTimeoutFor(Buffer.byteLength(parts[i], 'utf8')) });
|
|
3284
|
+
progressed();
|
|
2171
3285
|
// 바이트로 적는다 — `.length` 는 UTF-16 단위라 한글이 3배로 어긋난다(splitAtLines 의 그 함정).
|
|
2172
3286
|
process.stderr.write(`genetics: 조각 ${i + 1}/${parts.length} ${dryRun ? '시험' : '올림'} `
|
|
2173
3287
|
+ `(${(Buffer.byteLength(parts[i], 'utf8') / 1048576).toFixed(1)}MB)\n`);
|
|
2174
3288
|
}
|
|
3289
|
+
if (!dryRun) ledgerAfterFull(uuid, last, coords, ledgerCtx);
|
|
2175
3290
|
// 〃 조각 올리기도 같다 — 마지막 조각까지 받았으면 그 uuid 에 대한 막음은 지금 안 선다(호환 C2 · 소속처 견줌은 N9).
|
|
2176
|
-
if (!dryRun)
|
|
2177
|
-
return { r: last, before, parts: parts.length, raw_sha: rawSha };
|
|
3291
|
+
if (!dryRun) pushIndexNote(uuid, tenant, last, rawSha);
|
|
3292
|
+
return { r: last, before, parts: parts.length, raw_sha: rawSha, plan, appended: 0 };
|
|
3293
|
+
}
|
|
3294
|
+
|
|
3295
|
+
/**
|
|
3296
|
+
* 성공한 올리기가 색인에 적는 것 — **한 자리**(FULL 셋과 덧붙임이 같은 줄을 지난다).
|
|
3297
|
+
* 서버가 받았으므로 서버발 막음 표(too_old·hidden·hold)를 지운다. 사람의 손 올리기가 실제로 푸는 자리다(호환 C2).
|
|
3298
|
+
* hidden·hold 표는 **받은 소속처가 그 표의 소속처일 때만** 지운다(호환 N9 — `autoClearOnPush()`).
|
|
3299
|
+
* ⚠ `raw_sha`(원본 전체의 sha)는 **FULL 에서만** 적는다(§2.10) — 덧붙임은 원본 전체를 안 읽으므로 그 값을 모른다.
|
|
3300
|
+
* 그래서 사본(dup) 판정의 비교 대상도 「색인에 적힌 지문」이 아니라 **지금 디스크의 파일**로 바뀌었다(`autoDupOf`).
|
|
3301
|
+
*/
|
|
3302
|
+
function pushIndexNote(uuid, tenant, r, rawSha) {
|
|
3303
|
+
indexNote(uuid, { src: 'push', tenant: r.tenant || tenant, tenant_id: r.tenant_id, head_id: r.head_id, head_ms: r.head_ms,
|
|
3304
|
+
...(rawSha ? { raw_sha: rawSha } : {}), auto: autoClearOnPush(r.tenant_id),
|
|
3305
|
+
...(r.tenant_slug ? { tenant_slug: r.tenant_slug } : {}) }, 'tx');
|
|
3306
|
+
}
|
|
3307
|
+
|
|
3308
|
+
/** 폴더 제안(§4.2)의 자리 — 본문 최빈 cwd → Cursor 창 폴더 → 이 프로세스. FULL 과 덧붙임이 같은 자를 쓴다. */
|
|
3309
|
+
function pushProposeFolder(sc, isCursorBody) {
|
|
3310
|
+
const place = sc.cwd_raw || (isCursorBody ? (cursorWorkspaceFolder() || '') : process.cwd());
|
|
3311
|
+
return place ? folderForPush(folderOf(place)) : null;
|
|
2178
3312
|
}
|
|
2179
3313
|
|
|
2180
3314
|
/* ── 코로케이션 심박 ───────────────────────────────────────────────────
|
|
@@ -2326,25 +3460,33 @@ function indexRowSame(a, b) {
|
|
|
2326
3460
|
/**
|
|
2327
3461
|
* @param {string} uuid
|
|
2328
3462
|
* @param {null|{tenant_id:number, tenant_slug?:string, tenant?:string, src?:string}} tenant 서버가 준 소속. null 이면 소속 칸을 안 건드린다
|
|
2329
|
-
* @param {object} patch 소속 밖 칸 — 소속 칸이 섞여 오면
|
|
3463
|
+
* @param {object|((row: object|null) => (object|null))} patch 소속 밖 칸 — 소속 칸이 섞여 오면 버린다. **함수면 잠금 안에서**
|
|
3464
|
+
* 파일의 그 행을 받아 patch 를 낸다(null 이면 안 쓴다) — 입구가 실패 수를 「읽고 +1」 하는 자리(race S6 · C7)
|
|
2330
3465
|
* @param {''|'tx'|'rx'} flow
|
|
2331
|
-
* @param {null|((row: object|null) => boolean)} [guard] 잠금 안에서 파일의 그 행을 보고 거짓이면 안 쓴다(
|
|
3466
|
+
* @param {null|((row: object|null) => boolean)} [guard] 잠금 안에서 파일의 그 행을 보고 거짓이면 안 쓴다(보류·막음 표의 견줌 — §5.5)
|
|
2332
3467
|
*/
|
|
2333
3468
|
function indexWrite(uuid, tenant, patch, flow, guard = null) {
|
|
2334
3469
|
if (typeof uuid !== 'string' || uuid === '' || uuid === '_v') return false;
|
|
2335
|
-
const p = {};
|
|
2336
|
-
for (const [k, v] of Object.entries(patch || {})) {
|
|
2337
|
-
if (k === '_v' || INDEX_TENANT_KEYS.includes(k) || v === undefined) continue;
|
|
2338
|
-
p[k] = v;
|
|
2339
|
-
}
|
|
2340
3470
|
const now = Date.now();
|
|
2341
|
-
|
|
2342
|
-
|
|
3471
|
+
const clean = (src) => {
|
|
3472
|
+
const p = {};
|
|
3473
|
+
for (const [k, v] of Object.entries(src || {})) {
|
|
3474
|
+
if (k === '_v' || INDEX_TENANT_KEYS.includes(k) || v === undefined) continue;
|
|
3475
|
+
p[k] = v;
|
|
3476
|
+
}
|
|
3477
|
+
if (flow === 'tx') p.tx_at = now;
|
|
3478
|
+
if (flow === 'rx') p.rx_at = now;
|
|
3479
|
+
return p;
|
|
3480
|
+
};
|
|
3481
|
+
const fixed = typeof patch === 'function' ? null : clean(patch);
|
|
2343
3482
|
// (판 8 — 적합성 F7) 성공한 올리기는 **그 소속처의** 곁 표도 걷는다 — 파일의 표와 같은 견줌(`autoBlockClearedBy()`).
|
|
2344
|
-
|
|
2345
|
-
|
|
2346
|
-
|
|
2347
|
-
|
|
3483
|
+
const unblockAside = (p) => {
|
|
3484
|
+
if (p.auto && typeof p.auto === 'object' && Object.hasOwn(p.auto, AUTO_UNBLOCK_KEY)) {
|
|
3485
|
+
const a = autoBlockAside.get(uuid.toLowerCase());
|
|
3486
|
+
if (a && autoBlockClearedBy(a, p.auto[AUTO_UNBLOCK_KEY])) autoBlockAside.delete(uuid.toLowerCase());
|
|
3487
|
+
}
|
|
3488
|
+
};
|
|
3489
|
+
if (fixed) unblockAside(fixed);
|
|
2348
3490
|
const r = jsonFileUpdate(INDEX_FILE, (cur) => {
|
|
2349
3491
|
const idx = { _v: 2, ...cur };
|
|
2350
3492
|
idx._v = 2; // 옛 판은 이 줄을 e?.tenant_id 로 걸러 무시한다
|
|
@@ -2353,6 +3495,13 @@ function indexWrite(uuid, tenant, patch, flow, guard = null) {
|
|
|
2353
3495
|
// patch 만 남기면 그 행의 나머지 칸(머리·소속)을 잃고, 그 반쪽 행이 메모리 사본까지 덮는다.
|
|
2354
3496
|
const was = had ? idx[uuid] : (indexMine.get(uuid) || {});
|
|
2355
3497
|
if (guard && !guard(had ? idx[uuid] : null)) return null;
|
|
3498
|
+
let p = fixed;
|
|
3499
|
+
if (!p) {
|
|
3500
|
+
const got = patch(had ? idx[uuid] : null);
|
|
3501
|
+
if (!got || typeof got !== 'object') return null;
|
|
3502
|
+
p = clean(got);
|
|
3503
|
+
unblockAside(p);
|
|
3504
|
+
}
|
|
2356
3505
|
let row = { ...was, ...p };
|
|
2357
3506
|
// `auto`(백오프·보류, §5.1)는 칸끼리 합친다 — 백오프만 고치는 쓰기가 사람이 세운 hold 를 지우면 안 된다.
|
|
2358
3507
|
if (p.auto && typeof p.auto === 'object') row.auto = { ...(was.auto && typeof was.auto === 'object' ? was.auto : {}), ...p.auto };
|
|
@@ -2415,24 +3564,6 @@ function indexNote(uuid, entry, flow = '') {
|
|
|
2415
3564
|
catch { /* 색인은 있으면 좋은 것이지 도구가 실패할 이유가 아니다 */ }
|
|
2416
3565
|
}
|
|
2417
3566
|
|
|
2418
|
-
/**
|
|
2419
|
-
* 자동 올리기의 **선점**(§5.5) — 이 기계의 창 여럿이 같은 후보를 한꺼번에 올리지 않게, 잠금 안에서 그 행의
|
|
2420
|
-
* `auto.next_at`(다음 시도 시각)을 보고 비어 있거나 지났을 때만 `until` 로 세운다. 보류(hold)면 안 잡는다.
|
|
2421
|
-
* 올리다 죽어도 선점은 `until` 에 저절로 풀린다 — 새 칸을 만들지 않고 백오프 칸 하나로 한다.
|
|
2422
|
-
*/
|
|
2423
|
-
function indexClaim(uuid, until, now = Date.now()) {
|
|
2424
|
-
let got = false;
|
|
2425
|
-
let ok = false;
|
|
2426
|
-
try {
|
|
2427
|
-
ok = indexWrite(uuid, null, { auto: { next_at: until } }, '', (row) => {
|
|
2428
|
-
const a = row?.auto;
|
|
2429
|
-
got = !(a?.hold === true || Number(a?.next_at) > now);
|
|
2430
|
-
return got;
|
|
2431
|
-
});
|
|
2432
|
-
} catch { return false; }
|
|
2433
|
-
return got && ok;
|
|
2434
|
-
}
|
|
2435
|
-
|
|
2436
3567
|
/**
|
|
2437
3568
|
* 사본의 **머리** — 마지막으로 남은 발화의 id·시각, 그리고 줄 수.
|
|
2438
3569
|
*
|
|
@@ -2459,7 +3590,13 @@ function transcriptHead(file) {
|
|
|
2459
3590
|
// 앞이 잘린 줄은 버린다 — 반쪽 JSON 은 파싱이 안 되지만, 버리는 것을 **의도로** 적어 둔다.
|
|
2460
3591
|
// (개행이 하나도 없으면 꼬리 전체가 잘린 한 줄이다 — 남는 것이 없다.)
|
|
2461
3592
|
const cut = from > 0 ? text.indexOf('\n') : -1;
|
|
2462
|
-
const
|
|
3593
|
+
const whole = from > 0 ? (cut < 0 ? '' : text.slice(cut + 1)) : text;
|
|
3594
|
+
// ★ **끊기 규칙**(§2.1 · C6): 개행 없는 끝 줄은 머리가 아니다 — 쓰는 중인 반쪽 줄이거나, 다 쓴 줄이어도 아직
|
|
3595
|
+
// 올릴 좌표(`raw_off` = 마지막 `\n` 바로 뒤) 밖이다. 서버가 가진 본문은 늘 `\n` 으로 끝나므로, 여기서 끝 줄을
|
|
3596
|
+
// 머리로 잡으면 덧붙임이 그 줄을 안 실은 채 머리만 앞서 SAME 으로 수렴하지 못한다(compat G1).
|
|
3597
|
+
// ⚠ jsonlHead 에 넣지 않는다 — 그 함수는 늘 `\n` 으로 끝나는 검열본(조각 봉투 head_ms)에도 쓰인다.
|
|
3598
|
+
const endNl = whole.lastIndexOf('\n');
|
|
3599
|
+
const head = jsonlHead(endNl < 0 ? '' : whole.slice(0, endNl + 1));
|
|
2463
3600
|
|
|
2464
3601
|
// 줄 수 — 늘어난 만큼만 센다. 처음 보는 파일·줄어든 파일은 처음부터 세되 **파일을 통째로 올리지 않는다**
|
|
2465
3602
|
// (2026-09-17: 38MB 전사를 `Buffer.alloc(size)` 로 올려 바이트 루프로 세던 자리가 부팅·압축 뒤의 버스트였다).
|
|
@@ -3207,7 +4344,6 @@ function autoPushSilence() {
|
|
|
3207
4344
|
*/
|
|
3208
4345
|
process.stderr.write(`${tenantNoticeText({ state: 'bound', auto_upload: true, env_off: true })}\n`);
|
|
3209
4346
|
}
|
|
3210
|
-
const autoPushAt = new Map();
|
|
3211
4347
|
/*
|
|
3212
4348
|
* §5.4 「bound·auto on」(동의 표시)을 도구 꼬리로 낸 **폴더들** — 그 폴더의 전사는 그 뒤에만 올라간다.
|
|
3213
4349
|
*
|
|
@@ -3252,7 +4388,10 @@ function autoConsentGiven(e) {
|
|
|
3252
4388
|
const AUTO_SCAN_EVERY_MS = 5 * 60_000; // 되찾기 훑기는 5분에 한 번
|
|
3253
4389
|
const AUTO_SCAN_MAX_AGE_MS = 30 * 24 * 3600_000; // 【선언】 mtime 30일 안
|
|
3254
4390
|
const AUTO_SCAN_PER_FOLDER = 200; // 폴더당 최대 200개
|
|
3255
|
-
|
|
4391
|
+
// 【선언】 검열본 32MB — 넘으면 안 올리고 한 번 말한다. 뜻은 **서버 사본 한 벌의 크기**다(DESIGN-session-delta-sync §3.1 · C7) —
|
|
4392
|
+
// 길이 FULL 뿐인 지금은 보내는 검열본 전체가 곧 그 사본이라 같은 값을 잰다. 덧붙임(C8)은 `row.bytes + 조각` 으로 잰다.
|
|
4393
|
+
// 확정 폴더(bound) 갈래에서만 건다 — 옛 길에는 안 씌운다(오너 결정 §0-1).
|
|
4394
|
+
const AUTO_BODY_MAX = 32 * 1024 * 1024;
|
|
3256
4395
|
const AUTO_BACKOFF_BASE_MS = 60_000; // 실패 백오프 1분 → 2분 → 4분 …
|
|
3257
4396
|
const AUTO_BACKOFF_CAP_MS = 6 * 3600_000; // … 6시간 상한
|
|
3258
4397
|
const AUTO_BACKOFF_LONG_MS = 24 * 3600_000; // 403·409·422·426 → 24시간
|
|
@@ -3306,6 +4445,11 @@ function autoClearOnPush(tenantId) {
|
|
|
3306
4445
|
* 늘 resolve 가 먼저라(§5.5) 새 서버의 답이 올리기 전에 다시 세운다. 옛 서버(409 만 말한다)에서는 프로세스마다
|
|
3307
4446
|
* 한 번 보낸 뒤 선다 — 두 소속처가 같은 uuid 를 함께 막은 드문 자리의 값이다. 푸는 길은 파일의 표와 같다
|
|
3308
4447
|
* (그 소속처로의 성공한 올리기 — `indexWrite()` 의 `unblock_tenant` · 그 소속처 resolve 의 `hold:false`).
|
|
4448
|
+
* ⚠ (C7) 설계(DESIGN-session-delta-sync §3.2)는 이 표를 「색인 쓰기가 실패할 때의 곁 표」로 읽고 걷는 목록에 넣었지만,
|
|
4449
|
+
* **이 표의 일은 그것이 아니다** — 색인 쓰기는 **성공한** 채로 「한 칸을 이미 다른 소속처가 쥐었다」(`'foreign'`)는 소유권의
|
|
4450
|
+
* 답이다. 입구가 「색인을 못 읽거나 못 쓰면 안 올린다」를 세워도 이 자리는 안 사라진다(걷으면 F7 이 되돌아온다: A 가 뺀
|
|
4451
|
+
* 대화가 B 의 hold:false 로 A 로 다시 간다). 그래서 **남긴다**. 걷으려면 색인 `auto.blocked` 를 소속처별로 두는 모양 바꿈이
|
|
4452
|
+
* 먼저다 — 그것은 옛 판이 읽는 칸의 모양이라 C7 밖의 결정이다(C8·오너로 넘김).
|
|
3309
4453
|
*/
|
|
3310
4454
|
const autoBlockAside = new Map();
|
|
3311
4455
|
|
|
@@ -3472,8 +4616,8 @@ function autoTooOldMark(uuid, tooOld, mtime) {
|
|
|
3472
4616
|
/**
|
|
3473
4617
|
* 후보 하나를 거른다(§5.5 3) — 빈 문자열이면 올려도 된다. **순수 함수**(시험이 표로 잰다).
|
|
3474
4618
|
* @param {{uuid:string, entry:?object, private_file?:boolean, row?:?object, raw_sha?:string, mtime?:number, head_ms?:number}} c
|
|
3475
|
-
* @param {{now:number, env_on:boolean,
|
|
3476
|
-
*
|
|
4619
|
+
* @param {{now:number, env_on:boolean, dupOf?:(c:object)=>boolean, holds?:Set<string>, holds_ok?:boolean}} ctx
|
|
4620
|
+
* dupOf: 이 후보와 바이트째 같은 다른 uuid 가 있나(첫 업로드 후보에만 · C8) · holds: 색인 밖 보류 명부(§5.8) · holds_ok=false 면 전부 보류
|
|
3477
4621
|
*/
|
|
3478
4622
|
function autoSkipReason(c, ctx) {
|
|
3479
4623
|
const e = c?.entry;
|
|
@@ -3504,13 +4648,10 @@ function autoSkipReason(c, ctx) {
|
|
|
3504
4648
|
* 답하면 푼다(`autoBlockFromResolve()` — 포털에서 가림·보류를 푼 사실이 이 기계에 닿는 길, 부록 D-30).
|
|
3505
4649
|
* ⚠ 옛 서버(`hold` 칸 없음)·`none` 답으로는 안 푼다 — 그때는 사람이 한 번 올리면 된다.
|
|
3506
4650
|
*/
|
|
3507
|
-
|
|
3508
|
-
|
|
3509
|
-
|
|
3510
|
-
|
|
3511
|
-
const aside = ctx.aside?.get?.(String(c.uuid || '').toLowerCase());
|
|
3512
|
-
if (aside && AUTO_STICKY_409.has(String(aside.code))
|
|
3513
|
-
&& Number(aside.tenant_id) > 0 && Number(aside.tenant_id) === Number(e.tenant_id)) return String(aside.code);
|
|
4651
|
+
// (판 8 — 적합성 F7) 파일의 표가 다른 소속처의 것이라 못 적은 막음은 이 프로세스의 곁 표(ctx.aside) — 소속처가 같을 때만 선다.
|
|
4652
|
+
// 판정은 옛 길의 입구와 **같은 한 벌**(`autoBlockedFor()` · C7)이다.
|
|
4653
|
+
const blocked = autoBlockedFor(row, e.tenant_id, ctx.aside?.get?.(String(c.uuid || '').toLowerCase()));
|
|
4654
|
+
if (blocked) return blocked;
|
|
3514
4655
|
/*
|
|
3515
4656
|
* 동의 앞의 대화는 안 올린다(D3) — 잰 자리가 없는 후보(순수 시험의 표)는 이 줄을 안 탄다.
|
|
3516
4657
|
* ① mtime 은 **싼 앞거르개**다: 파일이 하한보다 이르면 마지막 발화도 대개 그보다 이르다
|
|
@@ -3527,7 +4668,41 @@ function autoSkipReason(c, ctx) {
|
|
|
3527
4668
|
*/
|
|
3528
4669
|
const tooOld = Number(row.auto?.too_old) || 0;
|
|
3529
4670
|
if (tooOld > 0 && !(Number(c.head_ms) > 0 && Number(c.head_ms) >= tooOld)) return 'too_old';
|
|
3530
|
-
|
|
4671
|
+
/*
|
|
4672
|
+
* ★ **사본(dup) 은 첫 업로드 후보에만 건다 — 그리고 비교 대상은 「지금 디스크의 파일」이다**(§2.10 · cost G1 · C8).
|
|
4673
|
+
* 전에는 색인의 `raw_sha`(마지막으로 올린 때의 지문)끼리 견줬다. 덧붙임이 서면 그 칸은 **마지막 FULL 때의 값**이라
|
|
4674
|
+
* 자란 파일을 말하지 않는다 — 상대 쪽 지문이 낡아 거르개가 그냥 뚫린다(실패가 서버 계보로 옮겨 갈 뿐이다).
|
|
4675
|
+
* 그리고 이미 올라간 대화가 자라는 것은 사본이 아니라 제 자리로 자라는 것이다.
|
|
4676
|
+
*/
|
|
4677
|
+
if (c.first && typeof ctx.dupOf === 'function' && ctx.dupOf(c)) return 'dup';
|
|
4678
|
+
return '';
|
|
4679
|
+
}
|
|
4680
|
+
|
|
4681
|
+
/**
|
|
4682
|
+
* 사본(dup) 판정 — 이 후보와 **바이트째 같은** 다른 uuid 가 이 기계에 있나(§2.10). 없으면 ''.
|
|
4683
|
+
* 한 번 이상 올라간 다른 uuid 의 전사 가운데 **크기가 같은 것**만 해시해 견준다 — 평소 비용은 stat 뿐이다.
|
|
4684
|
+
* `rawShaOf` 는 (크기, mtime) 로 기억하므로 같은 파일을 두 번 안 읽는다.
|
|
4685
|
+
*/
|
|
4686
|
+
function autoDupOf(uuid, file, idx) {
|
|
4687
|
+
let size = -1;
|
|
4688
|
+
try { size = fs.statSync(file).size; } catch { return ''; }
|
|
4689
|
+
if (!(size > 0)) return '';
|
|
4690
|
+
const same = [];
|
|
4691
|
+
for (const [u, row] of Object.entries(idx || {})) {
|
|
4692
|
+
if (u === uuid || !AUTO_UUID_RE.test(u) || !row || typeof row !== 'object') continue;
|
|
4693
|
+
// 한 번도 안 올라간 행은 견줄 대상이 아니다 — 「이미 올라간 다른 대화의 사본인가」가 이 판정의 뜻이다.
|
|
4694
|
+
if (!autoRowOnServer(row) && !(typeof row.raw_sha === 'string' && row.raw_sha !== '')) continue;
|
|
4695
|
+
const f = transcriptOf(u);
|
|
4696
|
+
if (!f || f === file) continue;
|
|
4697
|
+
let s;
|
|
4698
|
+
try { s = fs.statSync(f); } catch { continue; }
|
|
4699
|
+
if (s.size !== size) continue;
|
|
4700
|
+
same.push({ uuid: u, file: f });
|
|
4701
|
+
}
|
|
4702
|
+
if (!same.length) return '';
|
|
4703
|
+
const my = rawShaOf(file);
|
|
4704
|
+
if (!my) return '';
|
|
4705
|
+
for (const m of same) if (rawShaOf(m.file) === my) return m.uuid;
|
|
3531
4706
|
return '';
|
|
3532
4707
|
}
|
|
3533
4708
|
|
|
@@ -3635,15 +4810,19 @@ function autoCandidates(now = Date.now()) {
|
|
|
3635
4810
|
return out;
|
|
3636
4811
|
}
|
|
3637
4812
|
|
|
4813
|
+
/**
|
|
4814
|
+
* 한 바퀴의 **잠금 밖 스냅숏** — 심박 **앞에서** 한 번 뜬다(beatTick). 거르개가 싸게 먼저 걸러 헛된 임대 잡기를 줄이는 데만 쓴다.
|
|
4815
|
+
* ⚠ 보증이 아니다: 입구(`autoEntrance()`)가 임대 안에서 색인을 **다시** 읽어 같은 판정을 한 번 더 한다(race M4 · C7).
|
|
4816
|
+
* `idx` 는 그때의 색인 사본 — 못 읽었으면 `{}` 다(여기서는 그래도 된다 — 입구가 못 읽음을 따로 가른다).
|
|
4817
|
+
*/
|
|
3638
4818
|
function autoContext(now = Date.now()) {
|
|
3639
|
-
const
|
|
3640
|
-
|
|
3641
|
-
|
|
3642
|
-
}
|
|
4819
|
+
const idx = indexRead();
|
|
4820
|
+
// 사본 거르개는 **부를 때** 잰다(첫 업로드 후보에만) — 바퀴마다 모든 행의 지문을 미리 짓지 않는다(§2.10 · cost G1).
|
|
4821
|
+
const dupOf = (c) => !!autoDupOf(c.uuid, c.file, idx);
|
|
3643
4822
|
const h = autoHoldsRead(); // 색인 밖 보류 명부 — 못 읽으면 이 바퀴는 아무것도 안 올린다(H2)
|
|
3644
4823
|
// 닫는 쪽으로 실패하되 **조용히는 아니다** — 이 상태가 이어지면 자동 올리기가 영영 안 돈다. 사람이 고칠 수 있게 한 번 말한다.
|
|
3645
4824
|
if (!h.ok) warnOnce('auto-holds-unreadable', `${HOLDS_FILE} 를 못 읽어 이 기계의 자동 올리기를 전부 멈춘다(무엇이 보류인지 모르므로). 그 파일을 고치거나 지우면 다시 돈다.`);
|
|
3646
|
-
return { now, env_on: autoPushOn(),
|
|
4825
|
+
return { now, idx, env_on: autoPushOn(), dupOf, holds: h.set, holds_ok: h.ok, aside: autoBlockAside };
|
|
3647
4826
|
}
|
|
3648
4827
|
|
|
3649
4828
|
/** 서버 최소 판보다 낡았는가 — 그러면 자동 올리기를 안 두드린다(서버가 426 으로 막을 자리다, §4.3 관문 2). */
|
|
@@ -3708,13 +4887,18 @@ function autoBeatRecs(cands, ctx) {
|
|
|
3708
4887
|
/**
|
|
3709
4888
|
* 이 대화가 거쳐 간 자리 **전부**에 `.genetics-private` 이 있나(개인정보 검토 m1).
|
|
3710
4889
|
* 최빈 자리 하나만 보면, 세션의 일부가 사적인 하위 폴더에서 돌아도 그 대화가 통째로 올라간다.
|
|
4890
|
+
* ★ 재료는 `cwd_hist` 의 **열쇠 전부**다(§2.2 · C6) — 예전 재료(`cwds_raw`)는 빈도순 상위 64 로 잘려서, 한 번만 들른
|
|
4891
|
+
* 사적 폴더(65위 아래)가 판정에서 빠졌다. `cwd_hist` 가 없는 입력(옛 모양)만 `cwds_raw` 로 물러선다.
|
|
3711
4892
|
*/
|
|
3712
4893
|
function autoPrivateAnyCwd(sc, stopReal) {
|
|
3713
|
-
|
|
4894
|
+
const places = sc?.cwd_hist && typeof sc.cwd_hist === 'object' ? Object.keys(sc.cwd_hist)
|
|
4895
|
+
: (Array.isArray(sc?.cwds_raw) ? sc.cwds_raw : []);
|
|
4896
|
+
for (const raw of places) {
|
|
3714
4897
|
let r = String(raw || '');
|
|
3715
4898
|
if (!r) continue;
|
|
3716
4899
|
try { r = fs.realpathSync(r); } catch { /* 없는 자리면 준 값 그대로 */ }
|
|
3717
|
-
|
|
4900
|
+
// 멈출 자리는 한 곳(확정 폴더의 뿌리)이거나, 자리마다 정하는 함수(옛 길 — 그 자리의 git 뿌리, C7)다.
|
|
4901
|
+
if (privateFileFor(r, typeof stopReal === 'function' ? stopReal(r) : stopReal)) return true;
|
|
3718
4902
|
}
|
|
3719
4903
|
return false;
|
|
3720
4904
|
}
|
|
@@ -3722,6 +4906,8 @@ function autoPrivateAnyCwd(sc, stopReal) {
|
|
|
3722
4906
|
/** 보내기 직전 — 본문의 자리(최빈 cwd, realpath)가 그 확정 폴더 안인가. 맞으면 서버 관문 5·6 이 대조할 `folder` 를 낸다. */
|
|
3723
4907
|
function autoPrepare(sc, c) {
|
|
3724
4908
|
const e = c.entry;
|
|
4909
|
+
// 거쳐 간 자리가 너무 많으면 private 판정을 다 못 잰다 — 자동 길은 **닫힌 쪽**으로 멈춘다(§2.2). 사람의 push 는 이 함수를 안 지난다.
|
|
4910
|
+
if (autoHistOverflow(sc)) return { code: 'cwd_hist_overflow' };
|
|
3725
4911
|
let real = String(sc.cwd_raw || '');
|
|
3726
4912
|
if (!real) return { code: 'place_unreadable' };
|
|
3727
4913
|
try { real = fs.realpathSync(real); } catch { /* 없는 자리면 준 값 그대로 */ }
|
|
@@ -3739,122 +4925,522 @@ function autoPrepare(sc, c) {
|
|
|
3739
4925
|
return { folder: { kind: 'path', identity: id, subpath: '', path: bound } };
|
|
3740
4926
|
}
|
|
3741
4927
|
|
|
3742
|
-
|
|
3743
|
-
|
|
3744
|
-
|
|
3745
|
-
|
|
4928
|
+
/* ── 한 입구 (DESIGN-session-delta-sync §3 · C7) ─────────────────────────────────────────────────────
|
|
4929
|
+
*
|
|
4930
|
+
* 무엇을 푸는가: 이 기계에는 MCP 창이 열 개 넘게 돈다. 전에는 자동 올리기가 두 길(① 확정 폴더 · ② 옛 길)로 갈라져 있었고,
|
|
4931
|
+
* 「한 대화는 기계에서 한 번만 올린다」가 ① 에만, 그것도 **보내는 동안만** 섰다(`indexClaim` 선점 → 성공하면 `next_at:0` 으로
|
|
4932
|
+
* 되돌림 — 「방금 보냈다」를 아무도 못 봤다). ② 는 프로세스 메모리(`autoPushAt`)뿐이라 창 N 개가 같은 대화를 N 번 보냈다
|
|
4933
|
+
* (2026-09-21 실측: 5분 PUT 514회 중 68% 가 같은 머리).
|
|
4934
|
+
* 이제 **모든 자동 올리기가 이 입구 하나**를 지난다: 기계 공용 임대(`push/<uuid>.lease`, 기존 `fileLock` 원시) → 임대 안에서
|
|
4935
|
+
* 색인을 **다시 읽고** 모든 판정(간격·백오프·막음·닫힘·FULL 예산) → `pushSession`(사람과 같은 함수) → 결과를 잠금 안 mutate 로
|
|
4936
|
+
* → finally 에서 토큰으로 푼다. 판정을 임대 **밖**에서 하면 두 창이 모두 통과한 뒤 차례로 올린다(race M4) — 순서가 보증이다.
|
|
4937
|
+
* 정책 상태는 새 저장소가 아니라 지금 기계 공용인 색인 `auto.*` 에 둔다(`last_at` 칸 하나를 더했다).
|
|
4938
|
+
*/
|
|
4939
|
+
/**
|
|
4940
|
+
* 【선언】 임대의 묵음 한도 — **가장 큰 조각의 요청 상한** + 60초. 진전이 이만큼 없으면 남이 걷는다(§3.2 · race S1).
|
|
4941
|
+
* 상한이 바이트에 비례하므로(`apiTimeoutFor`) 여기서도 그 최대값을 쓴다 — 한 곳을 고치면 둘이 함께 움직인다(호환 1-B).
|
|
4942
|
+
* ⚠ 가장 큰 요청은 이제 **덧붙임 봉투**(`APPEND_ENV_MAX` 6.5MiB)다 — FULL 조각(검열본 6MiB)보다 크다. 둘 중 큰 쪽으로 잰다
|
|
4943
|
+
* (C8 반박 fail F4 — 전에는 선언한 「+60초」가 실제로 +44초였다).
|
|
4944
|
+
*/
|
|
4945
|
+
const LEASE_QUIET_MS = apiTimeoutFor(Math.max(PUSH_PART_MAX, APPEND_ENV_MAX)) + 60_000;
|
|
4946
|
+
/** 【선언】 사람의 push_session 이 임대를 기다리는 상한(§3.4) — 비동기로만(Atomics.wait 금지 · race S5). */
|
|
4947
|
+
const HUMAN_LEASE_WAIT_MS = 30_000;
|
|
4948
|
+
const HUMAN_LEASE_POLL_MS = 250;
|
|
4949
|
+
/*
|
|
4950
|
+
* 【선언】 **FULL 예산**(§5 불변식 「전체 재전송으로 폭주하지 않는다」 · C7 이 C8 로 미룬 것). C7 에 못 둔 까닭 둘을 이 커밋이 먼저 풀었다:
|
|
4951
|
+
* ① 재료가 안 쌓였다 — `full_log` 가 **대응표** 안에 있어서, 서버가 좌표를 안 돌려주면 `ledgerAfterFull()` 이 대응표를 통째로
|
|
4952
|
+
* 지우며 기록도 함께 갔다. → 기록을 **색인 `auto.full_log`** 로 옮겼다(정책 상태의 자리 · §2.11 「정책 상태는 대응표에 두지 않는다」).
|
|
4953
|
+
* ② 사람의 push 를 안 가렸다 — `ledgerAfterFull` 은 누가 불렀는지 모른다. → 기록하는 자를 **입구 하나**(`autoEntrance`)로 좁혔다.
|
|
4954
|
+
* 사람의 `push_session` 은 예산을 **세지도 않고 받지도 않는다**(`hooks.plan` 을 안 건다).
|
|
4955
|
+
* 그리고 뜻이 이제 선다: 길이 APPEND 라 예산은 **진짜 FULL(재기반)** 에만 걸린다(`PUSH_FULL_REBASE`) — 첫 업로드도, 옛 서버의
|
|
4956
|
+
* FULL 도 안 센다. 넘으면 **조용히 버리지 않는다**: 색인에 `last_code:'full_budget'` 을 남기고 `next_at` 을 한 자리가 비는 시각으로 민다.
|
|
4957
|
+
*/
|
|
4958
|
+
/*
|
|
4959
|
+
* 【선언】 **닫힌 쪽으로 멈추는 보류**(C6 넘김 · C7 결정): 사적 폴더(`private`)와 자리 4096 초과(`cwd_hist_overflow`).
|
|
4960
|
+
* 둘 다 서버가 아니라 이 기계가 본문을 재서 내리는 판정이라, **파일이 그대로면 답도 그대로다**(cwd_hist 열쇠는 덧붙이기만으로는
|
|
4961
|
+
* 줄지 않고, 표지 파일이 걷혀도 판정 재료는 같은 파일이다) — 전에는 일반 백오프(1분 → … 6시간)를 타서 그때마다 전사 전체를 다시
|
|
4962
|
+
* 읽고 검열했다. 이제 그때의 크기·mtime 을 `auto.closed` 에 적고 **파일이 바뀔 때까지 다시 안 본다**. 바뀌면(자람·교체) 백오프가
|
|
4963
|
+
* 지난 뒤 한 번 다시 잰다 — 사람이 표지를 걷었거나 전사가 갈렸을 수 있다. 판정은 크기·mtime `!==` 다(시계가 되돌아간 것도 「바뀜」).
|
|
4964
|
+
*/
|
|
4965
|
+
const AUTO_CLOSED_CODES = new Set(['private', 'cwd_hist_overflow']);
|
|
4966
|
+
|
|
4967
|
+
function leaseFile(uuid) {
|
|
4968
|
+
const f = ledgerFile(uuid); // 같은 uuid 모양 검사 · 같은 자리(push/) — 대응표 옆
|
|
4969
|
+
return f ? `${f.slice(0, -'.json'.length)}.lease` : '';
|
|
4970
|
+
}
|
|
4971
|
+
|
|
4972
|
+
/**
|
|
4973
|
+
* 임대를 **한 번만** 잡아 본다(비차단). `{h}` 잡음 · `{held:true}` 남이 쥐었다 · `{err}` 자리를 못 만들었다.
|
|
4974
|
+
* ⚠ `fileLock` 은 EEXIST 가 아닌 실패(자리 없음 등)도 null 이라, 자리를 먼저 만들고 null 이면 파일이 있는지로 가른다 —
|
|
4975
|
+
* 안 그러면 못 만든 자리가 「남이 올리는 중」으로 읽힌다.
|
|
4976
|
+
*/
|
|
4977
|
+
function leaseTake(uuid) {
|
|
4978
|
+
const lock = leaseFile(uuid);
|
|
4979
|
+
if (!lock) return { err: 'uuid' };
|
|
4980
|
+
try { fs.mkdirSync(path.dirname(lock), { recursive: true, mode: 0o700 }); } catch (e) { return { err: String(e?.code || 'mkdir') }; }
|
|
4981
|
+
// SIGKILL 로 죽은 창이 남긴 `<lease>.stale-*` — 임대는 `jsonFileUpdate` 를 안 지나 아무도 안 치웠다(반박 race·compat 2-D).
|
|
4982
|
+
sweepLeftovers(lock);
|
|
4983
|
+
const h = fileLock(lock, Date.now(), { stale: LEASE_QUIET_MS, lock });
|
|
4984
|
+
if (h) return { h };
|
|
4985
|
+
// ⚠ **못 읽는 임대를 「남이 쥠」으로 읽지 않는다**(반박 race). EACCES 면 `lockStaleText` 도 실패해 영영 조용히 안 올린다 —
|
|
4986
|
+
// 까닭을 내면 입구가 `warnOnce` 로 말한다.
|
|
4987
|
+
try { fs.readFileSync(lock, 'utf8'); } catch (e) { if (e?.code !== 'ENOENT') return { err: String(e?.code || 'read') }; }
|
|
4988
|
+
return fs.existsSync(lock) ? { held: true } : { err: 'open' };
|
|
4989
|
+
}
|
|
4990
|
+
|
|
4991
|
+
/** 진전(요청 하나가 끝남)이 있을 때만 임대의 mtime 을 민다 — 타이머로는 안 민다(race S1). 내 토큰일 때만. */
|
|
4992
|
+
function leaseTouch(h) {
|
|
3746
4993
|
try {
|
|
3747
|
-
|
|
3748
|
-
|
|
3749
|
-
|
|
3750
|
-
|
|
3751
|
-
|
|
3752
|
-
|
|
3753
|
-
|
|
3754
|
-
|
|
4994
|
+
if (fs.readFileSync(h.lock, 'utf8') !== h.token) return;
|
|
4995
|
+
const t = new Date();
|
|
4996
|
+
fs.utimesSync(h.lock, t, t);
|
|
4997
|
+
} catch { /* 없어졌으면 민 것이 없다 */ }
|
|
4998
|
+
}
|
|
4999
|
+
|
|
5000
|
+
/** 사람의 push_session 이 임대를 기다린다 — **비동기**(setTimeout)로만. 못 잡으면 null · 자리를 못 만들면 `{err}` 그대로. */
|
|
5001
|
+
async function leaseWait(uuid, ms = HUMAN_LEASE_WAIT_MS) {
|
|
5002
|
+
const until = Date.now() + ms;
|
|
5003
|
+
for (;;) {
|
|
5004
|
+
const got = leaseTake(uuid);
|
|
5005
|
+
if (!got.held) return got;
|
|
5006
|
+
if (Date.now() >= until) return got;
|
|
5007
|
+
await new Promise((r) => setTimeout(r, HUMAN_LEASE_POLL_MS));
|
|
5008
|
+
}
|
|
5009
|
+
}
|
|
5010
|
+
|
|
5011
|
+
/**
|
|
5012
|
+
* 간격 — 이 기계의 누군가가 이 대화를 `AUTO_PUSH_MIN_MS` 안에 올렸나. **순수 함수.**
|
|
5013
|
+
* 시계가 되돌아가 그 시각이 한참 미래면(간격보다 멀리) 그 값은 안 믿는다 — 그렇지 않으면 되돌린 만큼 멎는다.
|
|
5014
|
+
*
|
|
5015
|
+
* ★★ 재료가 **둘**이다(반박 compat 1-A): 새 판이 적는 `auto.last_at`, 그리고 **성공한 올리기가 언제나 적는 `tx_at`**
|
|
5016
|
+
* (`indexWrite(..., 'tx')` — 사람의 push 도, **옛 판의 두 길도** 적는다). 옛 판 ①은 성공하면 `next_at` 을 0 으로
|
|
5017
|
+
* 되돌리고 `last_at` 을 안 적어, `last_at` 만 보면 「옛 창이 방금 올렸다」가 색인 어디에도 안 남는다 — 그 순간
|
|
5018
|
+
* AHEAD 판정을 쥔 새 창이 그대로 또 보냈다(실측: 옛 판이 먼저 올리는 혼합 함대에서 2회). 이 기계의 MCP 는 창마다
|
|
5019
|
+
* 오래 살아 **혼합이 평상 상태**라, 이 한 칸이 「기계에서 uuid 마다 간격당 1회」를 옛 판 쪽에서도 세운다.
|
|
5020
|
+
*/
|
|
5021
|
+
function autoIntervalOf(row, now) {
|
|
5022
|
+
const at = Math.max(Number(row?.auto?.last_at) || 0, Number(row?.tx_at) || 0);
|
|
5023
|
+
return at > 0 && Math.abs(now - at) < AUTO_PUSH_MIN_MS;
|
|
5024
|
+
}
|
|
5025
|
+
|
|
5026
|
+
/**
|
|
5027
|
+
* 입구의 나머지 판정(보류·백오프·닫힘) — **순수 함수**. '' 이면 보낸다. (FULL 예산은 C8 — 위 【없음】 주석)
|
|
5028
|
+
* @param {object|null} row 색인의 그 행
|
|
5029
|
+
* @param {{now:number, size:number, mtime:number}} f 파일의 지금
|
|
5030
|
+
*/
|
|
5031
|
+
function autoGateOf(row, f) {
|
|
5032
|
+
const a = row?.auto && typeof row.auto === 'object' ? row.auto : {};
|
|
5033
|
+
if (a.hold === true) return 'hold';
|
|
5034
|
+
// 시계 가드는 간격과 **같은 규율**이다(반박 race) — 시계가 앞으로 뛴 뒤 적힌 `next_at` 은 백오프의 최대값(24시간)보다
|
|
5035
|
+
// 멀 수 없으므로, 그보다 먼 미래면 안 믿는다. 한쪽만 방어하면 그 대화가 그 시각까지 통째로 멎는다.
|
|
5036
|
+
const next = Number(a.next_at) || 0;
|
|
5037
|
+
if (next > f.now && next - f.now <= AUTO_BACKOFF_LONG_MS) return 'backoff';
|
|
5038
|
+
const c = a.closed;
|
|
5039
|
+
if (c && typeof c === 'object' && AUTO_CLOSED_CODES.has(String(c.code))
|
|
5040
|
+
&& Number(c.size) === Number(f.size) && Number(c.mtime) === Number(f.mtime)) return String(c.code);
|
|
5041
|
+
return '';
|
|
5042
|
+
}
|
|
5043
|
+
|
|
5044
|
+
/**
|
|
5045
|
+
* FULL 예산이 남았나 — **순수 함수**. 0 이면 남았다, 아니면 **한 자리가 비는 시각**(epoch ms)이다.
|
|
5046
|
+
* 재료는 색인 `auto.full_log`(최근 `PUSH_FULL_LOG_MAX` 개의 **진짜 FULL** 시각). 시계 가드는 간격·백오프와 같은 규율이다 —
|
|
5047
|
+
* 한참 미래의 기록은 안 믿는다(시계가 앞으로 뛴 뒤 적힌 값 하나가 그 대화를 영영 묶지 않게).
|
|
5048
|
+
* @param {object|null} row 색인의 그 행
|
|
5049
|
+
*/
|
|
5050
|
+
function autoFullBudgetOf(row, now) {
|
|
5051
|
+
const log = (Array.isArray(row?.auto?.full_log) ? row.auto.full_log : [])
|
|
5052
|
+
.map(Number).filter((t) => Number.isFinite(t) && t > 0 && now - t < PUSH_FULL_WINDOW_MS && t - now < PUSH_FULL_WINDOW_MS);
|
|
5053
|
+
if (log.length < PUSH_FULL_BUDGET) return 0;
|
|
5054
|
+
return Math.min(...log) + PUSH_FULL_WINDOW_MS;
|
|
5055
|
+
}
|
|
5056
|
+
|
|
5057
|
+
/** 이번 바퀴가 **예산이 세는 FULL** 인가 — 재기반이고, 첫 업로드가 아니다(§3.2 · C8). */
|
|
5058
|
+
function autoFullCounts(plan, verdict) {
|
|
5059
|
+
return plan?.mode === 'FULL' && PUSH_FULL_REBASE.has(String(plan.why || ''))
|
|
5060
|
+
&& verdict !== BEAT_VERDICT.UNKNOWN;
|
|
5061
|
+
}
|
|
5062
|
+
|
|
5063
|
+
/**
|
|
5064
|
+
* 그 행에 **그 소속처**에 대한 붙박이 막음(가림·보류 409)이 서 있나 — 코드 또는 ''. **순수 함수**(개인정보 NEW-4 · 판 8 F7).
|
|
5065
|
+
* 파일의 표는 소속처를 안 적은 옛 표이거나 같은 소속처일 때, 곁 표(`aside`)는 같은 소속처일 때만 선다.
|
|
5066
|
+
* `autoSkipReason()`(확정 폴더)과 옛 길의 입구(C7 · 오너 결정 §0-5)가 이 한 벌을 쓴다.
|
|
5067
|
+
*/
|
|
5068
|
+
function autoBlockedFor(row, tenantId, aside) {
|
|
5069
|
+
const tid = Number(tenantId) || 0;
|
|
5070
|
+
const blocked = row?.auto?.blocked;
|
|
5071
|
+
if (blocked && AUTO_STICKY_409.has(String(blocked.code))
|
|
5072
|
+
&& (!(Number(blocked.tenant_id) > 0) || Number(blocked.tenant_id) === tid)) return String(blocked.code);
|
|
5073
|
+
if (aside && AUTO_STICKY_409.has(String(aside.code))
|
|
5074
|
+
&& Number(aside.tenant_id) > 0 && Number(aside.tenant_id) === tid) return String(aside.code);
|
|
5075
|
+
return '';
|
|
5076
|
+
}
|
|
5077
|
+
|
|
5078
|
+
/** 거쳐 간 자리가 `CWD_HIST_MAX` 를 넘나 — 넘으면 private 판정을 다 못 잰다(§2.2). 확정 폴더(autoPrepare)와 옛 길이 같은 자를 쓴다. */
|
|
5079
|
+
function autoHistOverflow(sc) {
|
|
5080
|
+
return !!(sc?.cwd_hist && typeof sc.cwd_hist === 'object' && Object.keys(sc.cwd_hist).length > CWD_HIST_MAX);
|
|
5081
|
+
}
|
|
5082
|
+
|
|
5083
|
+
/**
|
|
5084
|
+
* 그 자리의 git 뿌리(`.git` 이 있는 가장 가까운 위 폴더) — 집·뿌리에서 멈춘다. 없으면 ''. git 을 부르지 않는다(자리가 수천일 수 있다).
|
|
5085
|
+
* 짝인 `privateFileFor` 와 **같은 규율로 60초 기억한다** — cwd_hist 열쇠가 4096 곳이면 한 번의 옛 길 검사에 `existsSync` 가
|
|
5086
|
+
* 수만 번이다(반박 policy).
|
|
5087
|
+
*/
|
|
5088
|
+
const gitRootSeen = new Map(); // real → { at, root }
|
|
5089
|
+
function gitRootAbove(real) {
|
|
5090
|
+
const key = String(real || '');
|
|
5091
|
+
const c = gitRootSeen.get(key);
|
|
5092
|
+
if (c && Date.now() - c.at < PRIVATE_TTL_MS) return c.root;
|
|
5093
|
+
let h = os.homedir();
|
|
5094
|
+
try { h = fs.realpathSync(h); } catch { /* */ }
|
|
5095
|
+
let d = key;
|
|
5096
|
+
let root = '';
|
|
5097
|
+
for (let i = 0; i < 64 && d && d !== '/' && d !== h; i++) {
|
|
5098
|
+
if (fs.existsSync(path.join(d, '.git'))) { root = d; break; }
|
|
5099
|
+
const up = path.dirname(d);
|
|
5100
|
+
if (up === d) break;
|
|
5101
|
+
d = up;
|
|
5102
|
+
}
|
|
5103
|
+
if (gitRootSeen.size > 8192) gitRootSeen.clear();
|
|
5104
|
+
gitRootSeen.set(key, { at: Date.now(), root });
|
|
5105
|
+
return root;
|
|
5106
|
+
}
|
|
5107
|
+
|
|
5108
|
+
/**
|
|
5109
|
+
* 옛 길의 보내기 전 검사(오너 결정 2026-09-21 · §0-1 의 private) — 자리 4096 초과면 `cwd_hist_overflow`, 거쳐 간 자리
|
|
5110
|
+
* **전부**(cwd_hist 열쇠) 중 하나라도 `.genetics-private` 아래면 `private`. 확정 바인딩이 없으니 멈출 자리는 **열쇠마다 그 git 뿌리,
|
|
5111
|
+
* 없으면 집**이다 — 비워 두면(그 자리 하나) git 저장소가 아닌 자리에서 **상위 폴더의 표지를 통째로 놓친다**(반박 policy 실측:
|
|
5112
|
+
* `~/secret/.genetics-private` + cwd `~/secret/work` 가 안 막혔다. 사람이 표지를 두는 가장 흔한 무늬다). `privateFileFor` 는
|
|
5113
|
+
* 집과 `/` 에서 멈추므로 집 밖 자리(예: /tmp)는 지금처럼 그 한 칸만 본다. 32MB·동의 문구·too_old·pull 은 **안 씌운다**(오너 결정).
|
|
5114
|
+
* Cursor 전사는 줄에 자리가 없어 이 검사가 볼 것이 없다(지금처럼 올라간다).
|
|
5115
|
+
*/
|
|
5116
|
+
function autoLegacyCheck(sc) {
|
|
5117
|
+
if (autoHistOverflow(sc)) return 'cwd_hist_overflow';
|
|
5118
|
+
// ⚠ 집은 **realpath 로** 준다 — `privateFileFor` 는 「자리가 멈출 자리 아래인가」를 글자로 재는데, macOS 의 `/var` 처럼
|
|
5119
|
+
// 집이 심링크 뒤에 있으면(`$HOME=/var/…` · 자리는 `/private/var/…`) 안 걸려 멈출 자리가 그 한 칸으로 되돌아간다(실측).
|
|
5120
|
+
let home = os.homedir();
|
|
5121
|
+
try { home = fs.realpathSync(home); } catch { /* 없는 집이면 준 값 그대로 */ }
|
|
5122
|
+
if (autoPrivateAnyCwd(sc, (r) => gitRootAbove(r) || home)) return 'private';
|
|
5123
|
+
return '';
|
|
5124
|
+
}
|
|
5125
|
+
|
|
5126
|
+
/**
|
|
5127
|
+
* 후보 하나의 갈래 — `'bound'` · `'legacy'` · `null`. **세 갈래를 명시한다 — 여집합으로 짜지 않는다**(§3.1 · privacy B5).
|
|
5128
|
+
* 판정 재료는 **그 전사 폴더의 resolve 캐시 답**(`t.entry`)이다 — `autoCandidates()` 에 들었는지가 아니다(그 목록은 pull 행·Cursor 를
|
|
5129
|
+
* 뺀다). 거르개는 잠금 밖 스냅숏(`ctx` — 심박 앞에서 뜬 것)으로 먼저 싸게 거르는 것이고, 입구가 임대 안에서 **다시** 본다.
|
|
5130
|
+
* 안 가는 까닭은 `t.why` 에 적는다(시험이 읽는다).
|
|
5131
|
+
*
|
|
5132
|
+
* · `bound` — 확정 바인딩 **그리고** 자동 켜짐 **그리고** 안 낡음 **그리고** 후보 목록 안. 거르개는 ① 그대로: 판 낡음
|
|
5133
|
+
* (`autoClientOld` 는 이 갈래만 끈다) · 옮겨짐 · 판정 UNKNOWN(첫 업로드)/AHEAD · 서버를 거친 행의 UNKNOWN
|
|
5134
|
+
* (가렸거나 걷음) · 동의 문구 · 429 대기 · too_old 채움 → `autoSkipReason()` → 머리·사본 → 다시
|
|
5135
|
+
* `autoSkipReason()` · 간격·백오프·닫힘.
|
|
5136
|
+
* · null ① — 확정 바인딩 **그리고** (꺼짐 | 낡음 | ambiguous). 오너가 끈 동의를 옛 길이 거스르지 않는다.
|
|
5137
|
+
* · `legacy` — 확정 바인딩 **없음**(답이 없거나 `unbound`), **또는 확정·자동 폴더인데 후보 목록 밖**(pull 행 — 아래 ★) ·
|
|
5138
|
+
* 판정 AHEAD **그리고** WANT_PUSH(서버가 올려 달라고 할 때만) ·
|
|
5139
|
+
* 색인에 소속(`_tenant_arg`) · 보류 명부를 읽을 수 있고 그 안에 없음 · `auto.hold` 아님 · 그 소속처의 붙박이 막음
|
|
5140
|
+
* 없음(§0-5) · 간격·백오프·닫힘. ①의 정책 거르개(32MB·동의·too_old·pull)는 **안 씌운다**(§0-1). 사적 폴더·4096 은
|
|
5141
|
+
* 보내기 직전(`autoLegacyCheck`)에 본다.
|
|
5142
|
+
* · null ② — 그 밖 전부(모르는 폴더 상태 포함).
|
|
5143
|
+
* @param {{uuid:string, v:?object, c:?object, entry:?object, row:?object, rec:?object, size:number, mtime:number}} t
|
|
5144
|
+
* @param {{now:number, holds?:Set<string>, holds_ok?:boolean, aside?:Map, client_old?:boolean,
|
|
5145
|
+
* consent?:(e:object)=>boolean, tenant_wait?:Map<number,number>, dupOf?:(c:object)=>boolean, env_on?:boolean}} ctx
|
|
5146
|
+
*/
|
|
5147
|
+
function autoPolicyOf(t, ctx) {
|
|
5148
|
+
const no = (why) => { t.why = why; return null; };
|
|
5149
|
+
const v = t.v;
|
|
5150
|
+
if (!v) return no('no_verdict');
|
|
5151
|
+
const e = t.entry && typeof t.entry === 'object' ? t.entry : null;
|
|
5152
|
+
const st = e ? String(e.state || '') : '';
|
|
5153
|
+
const row = t.row && typeof t.row === 'object' ? t.row : null;
|
|
5154
|
+
const snap = { now: ctx.now, size: t.size, mtime: t.mtime };
|
|
5155
|
+
/*
|
|
5156
|
+
* ★ **확정·자동 폴더인데 후보 목록 밖인 행**(`t.c` 없음 — `autoCandidates` 가 빼는 `origin:'pull'` 행. Cursor 는 애초에
|
|
5157
|
+
* 폴더 답을 안 받아 `st === ''` 다)은 **옛 길로 보낸다**. 그 행은 「내 확정 폴더에서 남의 세션을 이어받아 계속 일한 대화」이고
|
|
5158
|
+
* (`resume_session` 이 받은 사본에 `origin:'pull'` 을 찍는다), **옛 ②가 올리던 바로 그것**이다 — 옛 ②는 cands 에 있는
|
|
5159
|
+
* bound 행만 건너뛰었고 pull 행은 cands 에 없어 그대로 올렸다. 여기서 null 로 떨구면 오너 결정(「옛 길의 뜻은 그대로」) 밖의
|
|
5160
|
+
* 뜻 변경이 되고, 사람에게는 아무 말도 안 나간 채 그 대화의 자동 갱신이 통째로 멎는다(반박 policy ②). 설계 §3.1 의
|
|
5161
|
+
* 「Cursor·pull 행은 지금처럼 여기(legacy)로 들어온다」도 이 쪽이다.
|
|
5162
|
+
* ⚠ 자동이 **꺼졌거나·낡았거나·ambiguous** 인 폴더는 아래 null ① 그대로다(privacy B5) — 오너가 끈 동의를 옛 길이 안 거스른다.
|
|
5163
|
+
*/
|
|
5164
|
+
const boundOn = st === 'bound' && e.auto_upload === true && !e.stale;
|
|
5165
|
+
// ── bound ──
|
|
5166
|
+
if (boundOn && t.c) {
|
|
5167
|
+
if (ctx.client_old) return no('client_old'); // ① 만 끈다(서버가 426 으로 막을 자리)
|
|
5168
|
+
if (v.flags & BEAT_MOVED) return no('moved'); // 옮겨진 세션은 resolve 가 새 자리를 준다
|
|
5169
|
+
const first = v.verdict === BEAT_VERDICT.UNKNOWN;
|
|
5170
|
+
if (!first && v.verdict !== BEAT_VERDICT.AHEAD) return no('verdict'); // SAME·BEHIND 는 안 올린다
|
|
5171
|
+
const c = t.c;
|
|
5172
|
+
c.first = first; // 사본(dup) 은 첫 업로드 후보에만 건다(§2.10 · C8)
|
|
5173
|
+
// 색인이 「서버에 있다」고 아는 대화가 UNKNOWN 이면 사람이 가렸거나 걷은 것이다 — 첫 업로드가 아니다(§4.6).
|
|
5174
|
+
if (first && autoRowOnServer(row)) return no('row_on_server');
|
|
5175
|
+
// ★ 동의 문구가 먼저다(개인정보 M5·N8·N9) — 그 폴더의 §5.4 「bound·auto on」이 이 창에서 한 번 나간 뒤에만.
|
|
5176
|
+
if (!ctx.consent?.(e)) return no('no_consent'); // 판정자가 없으면 안 나간 것으로 친다(닫는 쪽)
|
|
5177
|
+
if ((ctx.tenant_wait?.get(Number(e.tenant_id)) || 0) > ctx.now) return no('tenant_wait');
|
|
5178
|
+
autoTooOldRefresh(c); // ★ 묻기 **전에** 머리를 채운다(호환 C2)
|
|
5179
|
+
let why = autoSkipReason(c, ctx);
|
|
5180
|
+
if (why) return no(why);
|
|
5181
|
+
autoHeadFill(c); // 서버 관문 11 이 재는 값(마지막 발화)으로 한 번 더(N3)
|
|
5182
|
+
// ⚠ 여기서 `rawShaOf(c.file)` 를 **조건 없이** 부르던 줄을 걷었다(§4.1 · C8): 그 한 줄이 **바퀴마다 전사 전체를 다시
|
|
5183
|
+
// sha256** 했고(크기·mtime 이 바뀌면 기억이 안 듣는다 — 자라는 전사는 매번 바뀐다), 쓰임은 사본 판정 한 곳뿐이었다.
|
|
5184
|
+
// 덧붙임의 「O(변화분)」 주장이 클라이언트 읽기에서 무너지는 자리다. 사본은 이제 첫 업로드 후보에서만 잰다.
|
|
5185
|
+
why = autoSkipReason(c, ctx); // 동의 앞 · 이미 올린 다른 uuid 의 사본(첫 업로드만)
|
|
5186
|
+
if (why) return no(why);
|
|
5187
|
+
if (autoIntervalOf(row, ctx.now)) return no('interval');
|
|
5188
|
+
why = autoGateOf(row, snap);
|
|
5189
|
+
return why ? no(why) : 'bound';
|
|
5190
|
+
}
|
|
5191
|
+
// ── null ① ── 확정 바인딩이 있는데 자동이 꺼졌거나·낡았거나·애매하다
|
|
5192
|
+
if (st === 'ambiguous') return no('ambiguous');
|
|
5193
|
+
if (st === 'bound' && !boundOn) return no(e.stale ? 'stale' : 'auto_off');
|
|
5194
|
+
// ── legacy ── 확정 바인딩이 없다(답이 없거나 unbound) · 또는 확정·자동 폴더의 후보 목록 밖 행(위 ★)
|
|
5195
|
+
if (st === '' || st === 'unbound' || boundOn) {
|
|
5196
|
+
if (v.verdict !== BEAT_VERDICT.AHEAD || !(v.flags & BEAT_WANT_PUSH)) return no('not_wanted');
|
|
5197
|
+
if (!t.rec?._tenant_arg || !(Number(row?.tenant_id) > 0)) return no('no_tenant');
|
|
5198
|
+
if (ctx.holds_ok === false) return no('holds_unreadable'); // 보류 명부를 못 읽으면 옛 길도 안 간다(H2)
|
|
5199
|
+
if (ctx.holds?.has?.(String(t.uuid).toLowerCase())) return no('hold'); // no_auto_push — 색인이 절단돼도 남는 자리
|
|
5200
|
+
if (row?.auto?.hold === true) return no('hold');
|
|
5201
|
+
const blocked = autoBlockedFor(row, row.tenant_id, ctx.aside?.get?.(String(t.uuid).toLowerCase()));
|
|
5202
|
+
if (blocked) return no(blocked); // 가림·보류 409 뒤에는 멈춘다(오너 결정 §0-5)
|
|
5203
|
+
if (autoIntervalOf(row, ctx.now)) return no('interval');
|
|
5204
|
+
const why = autoGateOf(row, snap);
|
|
5205
|
+
return why ? no(why) : 'legacy';
|
|
5206
|
+
}
|
|
5207
|
+
// ── null ② ── 그 밖(모르는 폴더 상태)
|
|
5208
|
+
return no(`state:${st}`);
|
|
5209
|
+
}
|
|
5210
|
+
|
|
5211
|
+
/**
|
|
5212
|
+
* 입구(§3.2) — 임대 → 임대 안에서 색인·대응표를 **다시** 읽고 모든 판정 → `pushSession` → 결과를 잠금 안 mutate 로 → 푼다.
|
|
5213
|
+
* @return {Promise<{sent: boolean, why: string}>} sent = 보내기를 시도했다(성공이든 실패든 — 한 바퀴에 한 건의 셈)
|
|
5214
|
+
*/
|
|
5215
|
+
async function autoEntrance(t, cls, ctx) {
|
|
5216
|
+
const u = t.uuid;
|
|
5217
|
+
const u8 = u.slice(0, 8);
|
|
5218
|
+
const lease = leaseTake(u);
|
|
5219
|
+
if (!lease.h) {
|
|
5220
|
+
if (lease.err) warnOnce(`lease-${lease.err}`, `자동 올리기 임대 자리(${pushDir()})를 못 만들어 올리지 않는다(${lease.err}).`);
|
|
5221
|
+
return { sent: false, why: lease.held ? 'held_elsewhere' : `lease_${lease.err}` };
|
|
5222
|
+
}
|
|
5223
|
+
try {
|
|
5224
|
+
// ── 2. 임대 안에서 다시 읽는다 — 색인을 못 읽으면 올리지 않는다(닫는 쪽 · §5.1). `indexRead()` 는 못 읽음을 {} 로 삼키므로 직접 본다.
|
|
5225
|
+
const got = jsonReadRetry(INDEX_FILE);
|
|
5226
|
+
if (got.err || (got.raw !== null && !got.obj)) {
|
|
5227
|
+
warnOnce('auto-index-unreadable', `${INDEX_FILE} 를 못 읽어 자동 올리기를 멈춘다(무엇을 언제 올렸는지 모르므로) — 그 파일을 고치면 다시 돈다.`);
|
|
5228
|
+
return { sent: false, why: 'index_unreadable' };
|
|
5229
|
+
}
|
|
5230
|
+
const row = got.obj?.[u] && typeof got.obj[u] === 'object' ? got.obj[u] : null;
|
|
5231
|
+
const holds = autoHoldsRead();
|
|
5232
|
+
if (!holds.ok) return { sent: false, why: 'holds_unreadable' };
|
|
5233
|
+
const L = ledgerReadStrict(u);
|
|
5234
|
+
if (!L.ok) {
|
|
5235
|
+
warnOnce(`auto-ledger-unreadable-${u}`, `${u8} 의 대응표를 못 읽어 자동으로 안 올린다 — ${ledgerFile(u)} 의 권한을 고치거나 지우면 다시 돈다.`);
|
|
5236
|
+
return { sent: false, why: 'ledger_unreadable' };
|
|
5237
|
+
}
|
|
5238
|
+
let st;
|
|
5239
|
+
try { st = fs.statSync(t.file); } catch { return { sent: false, why: 'gone' }; }
|
|
3755
5240
|
const now = Date.now();
|
|
3756
|
-
const
|
|
3757
|
-
|
|
3758
|
-
|
|
3759
|
-
|
|
3760
|
-
|
|
3761
|
-
|
|
3762
|
-
|
|
3763
|
-
|
|
3764
|
-
|
|
3765
|
-
const
|
|
3766
|
-
|
|
3767
|
-
|
|
3768
|
-
|
|
3769
|
-
|
|
3770
|
-
|
|
3771
|
-
|
|
5241
|
+
const f = { now, size: st.size, mtime: st.mtimeMs };
|
|
5242
|
+
const aside = autoBlockAside.get(u.toLowerCase());
|
|
5243
|
+
let tenantArg;
|
|
5244
|
+
let tid;
|
|
5245
|
+
if (cls === 'bound') {
|
|
5246
|
+
const c = t.c;
|
|
5247
|
+
c.row = row;
|
|
5248
|
+
if (c.v?.verdict === BEAT_VERDICT.UNKNOWN && autoRowOnServer(row)) return { sent: false, why: 'row_on_server' };
|
|
5249
|
+
// 사본 거르개도 **임대 안에서 다시 읽은 색인**으로 짓는다(잠금 밖 스냅숏이 아니라 · race M4).
|
|
5250
|
+
const why = autoSkipReason(c, { now, env_on: autoPushOn(), dupOf: (x) => !!autoDupOf(x.uuid, x.file, got.obj || {}),
|
|
5251
|
+
holds: holds.set, holds_ok: holds.ok, aside: autoBlockAside });
|
|
5252
|
+
if (why) return { sent: false, why };
|
|
5253
|
+
tid = Number(c.entry.tenant_id) || 0;
|
|
5254
|
+
// 429 `auto_rate` 대기도 임대 안에서 **다시** 본다 — 잠금 밖 스냅숏에서만 보면 기다리는 동안 옆 창이 세운 대기를 못 본다(반박 policy).
|
|
5255
|
+
if ((autoTenantWait.get(tid) || 0) > now) return { sent: false, why: 'tenant_wait' };
|
|
5256
|
+
tenantArg = c.entry.tenant_slug || c.entry.tenant;
|
|
5257
|
+
} else {
|
|
5258
|
+
if (holds.set.has(u.toLowerCase()) || row?.auto?.hold === true) return { sent: false, why: 'hold' };
|
|
5259
|
+
tenantArg = tenantArgOf(row);
|
|
5260
|
+
tid = Number(row?.tenant_id) || 0;
|
|
5261
|
+
if (!tenantArg || !(tid > 0)) return { sent: false, why: 'no_tenant' };
|
|
5262
|
+
const blocked = autoBlockedFor(row, tid, aside);
|
|
5263
|
+
if (blocked) return { sent: false, why: blocked };
|
|
5264
|
+
}
|
|
5265
|
+
if (autoIntervalOf(row, now)) return { sent: false, why: 'interval' };
|
|
5266
|
+
const gate = autoGateOf(row, f);
|
|
5267
|
+
if (gate) return { sent: false, why: gate };
|
|
5268
|
+
// ── 3. 보낸다 — 사람과 같은 함수. 진전이 있을 때만 임대 시계를 민다.
|
|
5269
|
+
// 길(덧붙임/전체)은 `pushSession` 안의 `planPush` 이 **이 임대 안에서** 고른다(§3.2). 여기서 거는 것은 **예산** 하나다:
|
|
5270
|
+
// 진짜 FULL(재기반)만 세고, 첫 업로드와 옛 서버의 FULL 은 안 센다. 넘으면 `full_budget` 으로 멎고 그 상태가 색인에 남는다.
|
|
5271
|
+
const progress = () => leaseTouch(lease.h);
|
|
5272
|
+
const budgetAt = autoFullBudgetOf(row, now);
|
|
5273
|
+
const planGate = (plan) => (budgetAt > 0 && autoFullCounts(plan, t.v?.verdict) ? 'full_budget' : '');
|
|
5274
|
+
let r = null;
|
|
5275
|
+
let plan = null;
|
|
5276
|
+
try {
|
|
5277
|
+
({ r, plan } = cls === 'bound'
|
|
5278
|
+
? await pushSession(tenantArg, u, '', false, 'skip',
|
|
5279
|
+
{ binding_id: Number(t.c.entry.binding_id) || 0, device: deviceId(), prepare: (sc) => autoPrepare(sc, t.c) },
|
|
5280
|
+
{ progress, plan: planGate }, tid)
|
|
5281
|
+
: await pushSession(tenantArg, u, '', false, 'skip', null, { check: autoLegacyCheck, progress, plan: planGate }, tid));
|
|
5282
|
+
} catch (err) {
|
|
5283
|
+
if (String(err?.code || '') === 'full_budget') {
|
|
5284
|
+
// **조용히 버리지 않는다** — 다음 시간으로 미루고 까닭을 남긴다(사람이 「왜 안 올라가나」를 색인에서 읽는다).
|
|
5285
|
+
indexPatch(u, { auto: { fails: 0, next_at: budgetAt, last_code: 'full_budget' } });
|
|
5286
|
+
warnOnce(`auto-full-budget-${u}`, `${u8} 는 한 시간에 전체 재전송이 ${PUSH_FULL_BUDGET}번을 넘어 다음 시간까지 미룬다(덧붙임이 설 때까지).`);
|
|
5287
|
+
return { sent: false, why: 'full_budget' };
|
|
5288
|
+
}
|
|
5289
|
+
autoEntranceFailed(t, cls, err, { tid, tenant: cls === 'bound' ? (t.c.entry.tenant || t.c.entry.tenant_slug) : (row?.tenant || tenantArg), size: st.size, mtime: st.mtimeMs });
|
|
5290
|
+
return { sent: true, why: '' };
|
|
3772
5291
|
}
|
|
3773
5292
|
/*
|
|
3774
|
-
*
|
|
3775
|
-
*
|
|
3776
|
-
*
|
|
3777
|
-
*
|
|
3778
|
-
* 푸는 길은 `autoSkipReason()` 의 「푸는 길」 넷뿐이다(그 소속처로의 성공한 올리기 · 소속처 바뀜 · 그 소속처 resolve 의 `same`·`hold:false`).
|
|
5293
|
+
* ── 4. 결과는 잠금 안 mutate 로. `next_at = now + 간격` 은 같은 기계의 **옛 판 ①**(색인 next_at 을 읽는다)도 간격을
|
|
5294
|
+
* 지키게 하는 비춤이다 — 전에는 성공하면 `next_at:0` 으로 되돌려 「방금 보냈다」를 아무도 못 봤다.
|
|
5295
|
+
* ⚠ **성공한 뒤의 뒷처리는 제 try 로 가른다**(반박 race). 전에는 같은 catch 라, 색인 쓰기나 stderr(EPIPE)가 던지면
|
|
5296
|
+
* **보낸 것을 실패로 적어** fails 를 올리고 백오프를 걸었다.
|
|
3779
5297
|
*/
|
|
3780
|
-
|
|
3781
|
-
|
|
3782
|
-
//
|
|
3783
|
-
|
|
3784
|
-
|
|
3785
|
-
|
|
3786
|
-
|
|
3787
|
-
|
|
3788
|
-
|
|
3789
|
-
|
|
3790
|
-
|
|
3791
|
-
|
|
5298
|
+
try {
|
|
5299
|
+
const done = Date.now();
|
|
5300
|
+
// ★ 예산의 기록은 **여기 한 곳**에서만 선다 — 진짜 FULL(재기반)이 **성공한** 때뿐이다(사람의 push 는 이 길로 안 온다).
|
|
5301
|
+
const counts = autoFullCounts(plan, t.v?.verdict);
|
|
5302
|
+
const wrote = indexWrite(u, null, (cur) => {
|
|
5303
|
+
const log = (Array.isArray(cur?.auto?.full_log) ? cur.auto.full_log : []).map(Number).filter((x) => Number.isFinite(x) && x > 0);
|
|
5304
|
+
return { auto: { last_at: done, fails: 0, next_at: done + AUTO_PUSH_MIN_MS, last_code: '', closed: null,
|
|
5305
|
+
...(counts ? { full_log: [...log.slice(-(PUSH_FULL_LOG_MAX - 1)), done] } : {}),
|
|
5306
|
+
...autoClearOnPush(Number(r?.tenant_id) || tid) } };
|
|
5307
|
+
}, '');
|
|
5308
|
+
if (!wrote) warnOnce(`auto-index-write-${u}`, `${u8} 를 올렸지만 색인에 못 적었다 — 옆 창이 간격을 못 볼 수 있다.`);
|
|
5309
|
+
const how = plan?.mode === 'APPEND' || plan?.mode === 'ASK' ? '덧붙여' : '통째로';
|
|
5310
|
+
process.stderr.write(cls === 'bound'
|
|
5311
|
+
? `genetics: ${u8} 를 확정 폴더의 자동 올리기로 ${how} 올렸다 — «${r.tenant || t.c.entry.tenant}» · ${r.lines}줄.\n`
|
|
5312
|
+
: `genetics: ${u8} 를 자동으로 ${how} 올렸다 — ${r.lines}줄(서버가 낡아 있었다).\n`);
|
|
5313
|
+
} catch { /* 보낸 것은 성공이다 — 뒷처리 실패로 실패를 적지 않는다 */ }
|
|
5314
|
+
return { sent: true, why: '' };
|
|
5315
|
+
} finally {
|
|
5316
|
+
fileUnlock(lease.h);
|
|
3792
5317
|
}
|
|
3793
5318
|
}
|
|
3794
5319
|
|
|
3795
|
-
/**
|
|
3796
|
-
|
|
5320
|
+
/** 대응표를 읽는다 — 없으면 `{ok:true, obj:null}` · **있는데 못 읽으면(권한) `{ok:false}`** · 깨진 JSON 은 캐시일 뿐이라 없는 것으로. */
|
|
5321
|
+
function ledgerReadStrict(uuid) {
|
|
5322
|
+
const f = ledgerFile(uuid);
|
|
5323
|
+
if (!f) return { ok: true, obj: null };
|
|
5324
|
+
let raw;
|
|
5325
|
+
try { raw = fs.readFileSync(f, 'utf8'); } catch (e) { return e?.code === 'ENOENT' ? { ok: true, obj: null } : { ok: false, obj: null }; }
|
|
5326
|
+
try { const o = JSON.parse(raw); return { ok: true, obj: o && typeof o === 'object' && !Array.isArray(o) ? o : null }; } catch { return { ok: true, obj: null }; }
|
|
5327
|
+
}
|
|
5328
|
+
|
|
5329
|
+
/**
|
|
5330
|
+
* 입구의 실패 규칙 **한 곳**(§3.2 4 · §5.1) — 확정 폴더와 옛 길이 같은 규칙을 탄다(옛 길은 전에 stderr 한 줄뿐이라 409 가림을 받고도
|
|
5331
|
+
* 90초마다 다시 보냈다 — 오너 결정 §0-5 로 붙박이). 실패 수는 잠금 안에서 파일의 값을 읽어 +1 한다(race S6).
|
|
5332
|
+
*/
|
|
5333
|
+
function autoEntranceFailed(t, cls, err, o) {
|
|
5334
|
+
const u = t.uuid;
|
|
5335
|
+
const u8 = u.slice(0, 8);
|
|
5336
|
+
const status = Number(err?.status) || 0;
|
|
5337
|
+
const code = String(err?.code || (status ? `http_${status}` : 'error'));
|
|
5338
|
+
const now = Date.now();
|
|
5339
|
+
const at = { last_at: now };
|
|
5340
|
+
if (status === 429 && code === 'auto_rate') autoTenantWait.set(Number(o.tid) || 0, now + autoBackoffMs(1, status, err?.retry_after));
|
|
5341
|
+
if (status === 426) minClientTick(true); // 판 알림 뒤 24시간(§5.5 7)
|
|
5342
|
+
/*
|
|
5343
|
+
* ★ **`too_old` 는 그 세션에 영구다**(개인정보 N3 · 부록 D-7). 「동의 앞에 끝난 대화」는 내일도 같은 답이다 —
|
|
5344
|
+
* 24시간 백오프로 두면 같은 검열본이 **날마다** 서버 디스크에 한 번 쓰였다 지워진다. 서버가 알려 준
|
|
5345
|
+
* 동의 시각을 적어 두고, 그 대화가 **자라서** 마지막 발화가 그 시각을 넘을 때까지 다시 안 보낸다.
|
|
5346
|
+
*/
|
|
5347
|
+
/*
|
|
5348
|
+
* ⚠ 이 자리는 **확정 폴더(①) 몫이다** — 옛 길은 `too_old` 를 안 읽는다(오너 결정 §0-1: 32MB·동의·too_old·pull 을 안 씌운다).
|
|
5349
|
+
* 지금은 도달 불가이기도 하다: 서버의 `too_old` 는 바인딩 머리를 실은 요청에만 서는데(`lib/folder_binding.php` 의
|
|
5350
|
+
* `agent_session_gate_age` — `$auto === null` 이면 null) 옛 길은 그 머리를 안 싣는다. 뜻이 바뀌어 옛 길에도 오게 되면
|
|
5351
|
+
* `autoGateOf` 가 `too_old` 를 읽지 않으므로 90초마다 되풀이된다 — 그때는 게이트에 한 줄을 더해야 한다(반박 policy).
|
|
5352
|
+
*/
|
|
5353
|
+
if (status === 409 && code === 'too_old') {
|
|
5354
|
+
const t0 = Date.parse(String(err?.auto_upload_at || '').replace(' ', 'T'));
|
|
5355
|
+
const floor = Number.isFinite(t0) && t0 > 0 ? t0 : (autoFloorMs(t.entry) || now);
|
|
5356
|
+
// ⚠ 그때의 mtime 을 함께 적는다 — 다음 바퀴부터는 **파일이 바뀌었을 때만** 머리를 다시 읽는다(호환 C2).
|
|
5357
|
+
indexPatch(u, { auto: { ...at, fails: 0, next_at: 0, last_code: code, too_old: floor, too_old_mtime: Number(t.mtime) || 0 } });
|
|
5358
|
+
warnOnce(`auto-too-old-${u}`, `${u8} 는 자동 올리기를 켜기 전에 끝난 대화라 안 올린다 — 다시 안 두드린다(올리려면 push_session).`);
|
|
5359
|
+
return;
|
|
5360
|
+
}
|
|
5361
|
+
/*
|
|
5362
|
+
* ★★ **사람이 뺀 대화는 물러섬이 아니라 붙박이다**(개인정보 NEW-4 · 옛 길은 오너 결정 §0-5). `session_hidden`·`session_hold` 는
|
|
5363
|
+
* 사람의 손이 세운 답이라 24시간 뒤에도 같다 — 백오프로 두면 **검열된 전사 전체가 날마다** 그 소속처 서버로 다시 보내지고
|
|
5364
|
+
* 파싱된다. 소속처를 함께 적어 두고, 그 소속처에 대해서만 다시 안 보낸다. 푸는 길은 `autoSkipReason()` 의 「푸는 길」 넷뿐이다.
|
|
5365
|
+
*/
|
|
5366
|
+
if (status === 409 && AUTO_STICKY_409.has(code)) {
|
|
5367
|
+
indexPatch(u, { auto: { ...at, fails: 0, next_at: 0, last_code: code } });
|
|
5368
|
+
// (판 8 — 적합성 F7) 표는 따로, 잠금 안 견줌으로 — 다른 소속처가 세운 표는 안 덮고 곁 표에 둔다.
|
|
5369
|
+
try { autoBlockPut(u, code, Number(o.tid) || 0, now); } catch { /* 못 적으면 다음 바퀴의 409 가 다시 세운다 */ }
|
|
5370
|
+
warnOnce(`auto-blocked-${u}-${code}`, code === 'session_hidden'
|
|
5371
|
+
? `${u8} 는 사람이 «${o.tenant}» 에서 가렸거나 걷은 대화라 자동으로 안 올린다 — 다시 안 두드린다(정말 올려야 하면 push_session).`
|
|
5372
|
+
: `${u8} 는 «${o.tenant}» 에서 「자동으로 올리지 않음」으로 표시된 대화라 안 올린다 — 다시 안 두드린다(포털 「폴더」의 업로드 목록에서 푼다).`);
|
|
5373
|
+
return;
|
|
5374
|
+
}
|
|
5375
|
+
/*
|
|
5376
|
+
* ★ **`behind` 는 실패가 아니다**(C6 · §2.9 race M6). 이 기계의 다른 창이 같은 대화를 **더 뒤까지** 먼저 올렸다 — 물러서지 않고
|
|
5377
|
+
* (fails 0) 간격만 비춘다(`next_at = now + 간격` — 성공과 같은 비춤, C7). 따라잡기는 C8 몫이다.
|
|
5378
|
+
*/
|
|
5379
|
+
if (status === 409 && code === 'behind') {
|
|
5380
|
+
indexPatch(u, { auto: { ...at, fails: 0, next_at: now + AUTO_PUSH_MIN_MS, last_code: code } });
|
|
5381
|
+
return;
|
|
5382
|
+
}
|
|
5383
|
+
/*
|
|
5384
|
+
* ★ **`caught_up` 도 실패가 아니다**(C8). 덧붙임이 서버 좌표를 따라잡고 보니 보낼 줄이 없었다 — 대응표는 세웠고 전송은 0이다.
|
|
5385
|
+
* 백오프를 걸면 「좌표만 어긋났던」 대화가 몇 시간 멎는다. `behind` 와 같은 대우(fails 0 · 간격만 비춤).
|
|
5386
|
+
*/
|
|
5387
|
+
if (code === 'caught_up') {
|
|
5388
|
+
indexPatch(u, { auto: { ...at, fails: 0, next_at: now + AUTO_PUSH_MIN_MS, last_code: code } });
|
|
5389
|
+
return;
|
|
5390
|
+
}
|
|
5391
|
+
const closed = AUTO_CLOSED_CODES.has(code) ? { closed: { code, size: Number(o.size) || 0, mtime: Number(o.mtime) || 0 } } : {};
|
|
5392
|
+
indexWrite(u, null, (row) => {
|
|
5393
|
+
const fails = (Number(row?.auto?.fails) || 0) + 1;
|
|
5394
|
+
return { auto: { ...at, fails, next_at: now + autoBackoffMs(fails, status, err?.retry_after), last_code: code, ...closed } };
|
|
5395
|
+
}, '');
|
|
5396
|
+
if (code === 'cwd_hist_overflow') warnOnce(`auto-cwd-hist-${u}`, `${u8} 는 거쳐 간 폴더가 ${CWD_HIST_MAX}곳을 넘어 사적 폴더 판정을 다 못 잰다 — 자동으로 안 올린다(올리려면 push_session).`);
|
|
5397
|
+
else if (code === 'private') warnOnce(`auto-private-${u}`, `${u8} 는 거쳐 간 자리에 .genetics-private 가 있어 자동으로 안 올린다(올리려면 push_session).`);
|
|
5398
|
+
else if (code === 'too_big') warnOnce(`auto-too-big-${u}`, `${u8} 는 검열본이 32MB 를 넘어 자동으로 올리지 않는다 — 올리려면 push_session.`);
|
|
5399
|
+
else beatSay(`auto-${u}-${code}`, `자동 올리기 보류(${u8} · ${code}) — ${err?.message || err}`);
|
|
5400
|
+
}
|
|
5401
|
+
|
|
5402
|
+
/**
|
|
5403
|
+
* 한 바퀴(§3.1) — ①과 ②를 합친다. 후보 = 확정 폴더 후보(`autoCandidates`, 내 세션 → 최근 순) + 심박이 AHEAD 라 한 나머지(옛 길).
|
|
5404
|
+
* 갈래는 `autoPolicyOf()` 가 정하고, 보내기를 **시도한** 한 건에서 멈춘다(한 바퀴에 한 건 — ①이 먼저라는 우선순위는 후보 순서가 진다).
|
|
5405
|
+
* 임대를 못 잡았거나 임대 안 판정에서 물러선 후보는 건너뛰고 다음 후보를 본다(보낸 것이 없으니 「한 건」을 안 쓴다).
|
|
5406
|
+
* @param {object|null} ctx0 심박 **앞에서** 뜬 스냅숏(`beatTick`) — 없으면 여기서 뜬다
|
|
5407
|
+
*/
|
|
5408
|
+
async function autoSyncRound(reply, recs, cands, ctx0 = null) {
|
|
3797
5409
|
if (!autoPushOn()) { autoPushSilence(); return; }
|
|
3798
5410
|
if (!reply) return;
|
|
3799
|
-
const ctx = autoContext();
|
|
5411
|
+
const ctx = ctx0 || autoContext();
|
|
3800
5412
|
const verdict = new Map(reply.map((v) => [v.uuid, v]));
|
|
3801
5413
|
for (const c of cands) {
|
|
3802
5414
|
const v = verdict.get(c.uuid);
|
|
3803
5415
|
if (v && (v.verdict === BEAT_VERDICT.SAME || v.verdict === BEAT_VERDICT.BEHIND)) autoSettled.set(c.uuid, `${c.size}:${c.mtime}`);
|
|
3804
5416
|
}
|
|
3805
|
-
|
|
3806
|
-
|
|
3807
|
-
|
|
3808
|
-
|
|
3809
|
-
|
|
3810
|
-
|
|
3811
|
-
|
|
3812
|
-
|
|
3813
|
-
// 다시 올리면 가림(deleted_at)이 풀린다(§4.6 — 지우는 손을 기계가 되돌리지 않는다).
|
|
3814
|
-
// ⚠ 「안다」의 자는 소속 칸이 아니라 **서버를 거친 표식**이다(autoRowOnServer) — mail 도구와 옛 판 입양이
|
|
3815
|
-
// 소속 칸만 적어서, 그 행을 여기서 건너뛰면 그 대화는 첫 업로드를 영영 못 한다(2026-09-15 실측).
|
|
3816
|
-
if (first && autoRowOnServer(c.row)) continue;
|
|
3817
|
-
/*
|
|
3818
|
-
* ★ **동의 문구가 먼저다**(개인정보 검토 M5·N8·N9). §5.4 「bound·auto on」 이 **그 폴더에 대해** 도구 응답
|
|
3819
|
-
* 꼬리로 한 번 나간 뒤에만 그 폴더의 전사를 올린다. 전에는 부팅 직후 첫 심박이 도구 호출보다 먼저 올려서,
|
|
3820
|
-
* genetics 도구를 한 번도 안 부른 세션이 **한 글자도 안 알린 채** 올라갔다.
|
|
3821
|
-
* ⚠ 깃발은 폴더별이고(N8: 창 하나가 두 확정 폴더를 오갈 수 있다), **옆 창의 전사에도 건다**
|
|
3822
|
-
* (N9: 후보의 대부분이 그것이다). 다만 옆 **대화 안에서는** 여전히 아무 말도 안 나간다 —
|
|
3823
|
-
* 그 한계는 `autoConsentSaid` 머리말과 §5.5 가 적어 둔다.
|
|
3824
|
-
*/
|
|
3825
|
-
if (!autoConsentGiven(c.entry)) continue;
|
|
3826
|
-
if (!first && ctx.now - (autoPushAt.get(c.uuid) || 0) < AUTO_PUSH_MIN_MS) continue;
|
|
3827
|
-
if ((autoTenantWait.get(Number(c.entry?.tenant_id)) || 0) > ctx.now) continue;
|
|
3828
|
-
autoTooOldRefresh(c); // ★ 묻기 **전에** 머리를 채운다(호환 C2) — 아래 autoHeadFill 은 이 뒤에 선다
|
|
3829
|
-
if (autoSkipReason(c, ctx)) continue;
|
|
3830
|
-
autoHeadFill(c); // 서버 관문 11 이 재는 값(마지막 발화)으로 한 번 더(N3)
|
|
3831
|
-
c.raw_sha = rawShaOf(c.file);
|
|
3832
|
-
if (autoSkipReason(c, ctx)) continue; // 동의 앞 · 이미 올린 다른 uuid 의 사본 — 다음 후보로
|
|
3833
|
-
if (!indexClaim(c.uuid, ctx.now + AUTO_PUSH_MIN_MS, ctx.now)) continue; // 옆 창이 먼저 잡았다
|
|
3834
|
-
autoPushAt.set(c.uuid, ctx.now);
|
|
3835
|
-
await autoUploadOne(c);
|
|
3836
|
-
return; // 한 바퀴에 한 건
|
|
3837
|
-
}
|
|
5417
|
+
const pctx = { ...ctx, client_old: autoClientOld(), consent: autoConsentGiven, tenant_wait: autoTenantWait };
|
|
5418
|
+
const byDir = autoFolderMap(resolveCacheRead(), ctx.now);
|
|
5419
|
+
const recOf = new Map((recs || []).map((r) => [r.uuid, r]));
|
|
5420
|
+
const list = [];
|
|
5421
|
+
const seen = new Set();
|
|
5422
|
+
for (const c of autoOrder(cands)) {
|
|
5423
|
+
seen.add(c.uuid);
|
|
5424
|
+
list.push({ uuid: c.uuid, c, file: c.file, size: c.size, mtime: c.mtime, entry: c.entry, row: c.row, rec: recOf.get(c.uuid) || null });
|
|
3838
5425
|
}
|
|
3839
|
-
// ② 옛 길 — 확정 바인딩이 없는 폴더에서 사람이 올린 세션이 앞섰을 때(서버가 올려 달라고 할 때만)
|
|
3840
5426
|
for (const v of reply) {
|
|
3841
|
-
if (v.verdict !== BEAT_VERDICT.AHEAD
|
|
3842
|
-
|
|
3843
|
-
const
|
|
3844
|
-
if (!
|
|
3845
|
-
|
|
3846
|
-
|
|
3847
|
-
|
|
3848
|
-
|
|
3849
|
-
|
|
3850
|
-
|
|
3851
|
-
|
|
3852
|
-
|
|
3853
|
-
|
|
3854
|
-
|
|
3855
|
-
|
|
3856
|
-
|
|
3857
|
-
return;
|
|
5427
|
+
if (seen.has(v.uuid) || v.verdict !== BEAT_VERDICT.AHEAD) continue;
|
|
5428
|
+
seen.add(v.uuid);
|
|
5429
|
+
const file = transcriptOf(v.uuid);
|
|
5430
|
+
if (!file) continue;
|
|
5431
|
+
let st;
|
|
5432
|
+
try { st = fs.statSync(file); } catch { continue; }
|
|
5433
|
+
const hit = isCursorTranscript(file) ? null : byDir.get(path.dirname(file));
|
|
5434
|
+
const row = ctx.idx?.[v.uuid] && typeof ctx.idx[v.uuid] === 'object' ? ctx.idx[v.uuid] : null;
|
|
5435
|
+
list.push({ uuid: v.uuid, c: null, file, size: st.size, mtime: st.mtimeMs, entry: hit ? hit.entry : null, row, rec: recOf.get(v.uuid) || null });
|
|
5436
|
+
}
|
|
5437
|
+
for (const t of list) {
|
|
5438
|
+
t.v = verdict.get(t.uuid) || null;
|
|
5439
|
+
if (t.c) t.c.v = t.v;
|
|
5440
|
+
const cls = autoPolicyOf(t, pctx);
|
|
5441
|
+
if (!cls) continue;
|
|
5442
|
+
const r = await autoEntrance(t, cls, pctx);
|
|
5443
|
+
if (r.sent) return; // 한 바퀴에 한 건
|
|
3858
5444
|
}
|
|
3859
5445
|
}
|
|
3860
5446
|
|
|
@@ -3903,7 +5489,8 @@ async function beatTick() {
|
|
|
3903
5489
|
// 확정·자동 폴더의 후보(§5.5) — 서버 소속을 이 기계가 모르는 사본도 판정을 받게 심박에 싣는다.
|
|
3904
5490
|
let cands = [];
|
|
3905
5491
|
let extra = [];
|
|
3906
|
-
|
|
5492
|
+
let ctx0 = null; // 심박 **앞의** 스냅숏 — 싼 앞거르개용일 뿐, 입구가 임대 안에서 다시 읽는다(C7)
|
|
5493
|
+
try { cands = autoCandidates(); ctx0 = autoContext(); extra = autoBeatRecs(cands, ctx0); } catch { /* 후보를 못 모아도 심박은 뛴다 */ }
|
|
3907
5494
|
const recs = beatTargets(extra);
|
|
3908
5495
|
if (!recs.length) return;
|
|
3909
5496
|
// **안 바뀌었으면 안 보낸다.** 이 설계가 실제로 버는 것은 바이트가 아니라 이것이다.
|
|
@@ -3914,7 +5501,7 @@ async function beatTick() {
|
|
|
3914
5501
|
const reply = await beatOnce(recs);
|
|
3915
5502
|
// 옮겨진 세션은 resolve 가 새 자리를 준다(심박 프레임은 그대로 — §4.5).
|
|
3916
5503
|
try { await resolveMoved(reply); } catch { /* 다음 바퀴에 다시 */ }
|
|
3917
|
-
try { await
|
|
5504
|
+
try { await autoSyncRound(reply, recs, cands, ctx0); } catch { /* 한 바퀴가 실패해도 심박은 계속 뛴다 */ }
|
|
3918
5505
|
} finally {
|
|
3919
5506
|
beatRunning = false;
|
|
3920
5507
|
}
|
|
@@ -4004,7 +5591,140 @@ async function stopBeat() {
|
|
|
4004
5591
|
try { await beatOnce(beatTargets(), { closing: true }); } catch { /* 최선만 다한다 */ }
|
|
4005
5592
|
}
|
|
4006
5593
|
|
|
5594
|
+
function taskReadTools() {
|
|
5595
|
+
const tenant = { type: 'string', minLength: 1, maxLength: 100, description: 'list_my_tenants에서 확인한 조직 slug' };
|
|
5596
|
+
return [
|
|
5597
|
+
{ name: 'list_tasks', description: '조직에서 함께 사용하는 할 일과 완료 조건을 찾습니다. uid로 get_task_result를 호출해 사람이 채택한 결과를 이어받으세요. 개인 대화나 초안은 포함하지 않습니다.',
|
|
5598
|
+
inputSchema: { type: 'object', additionalProperties: false, required: ['tenant'], properties: { tenant,
|
|
5599
|
+
limit: { type: 'integer', minimum: 1, maximum: 50, default: 20 }, offset: { type: 'integer', minimum: 0, maximum: 10000, default: 0 } } } },
|
|
5600
|
+
{ name: 'get_task_result', description: '할 일에서 사람이 채택한 현재 결과를 본인의 조직·Notion 원문 권한으로 읽습니다. 다시 사용할 때 재조회하세요. 할 일이 바뀌었으면 당시 기록과 현재 업무를 구분합니다.',
|
|
5601
|
+
inputSchema: { type: 'object', additionalProperties: false, required: ['tenant', 'task_uid'], properties: { tenant,
|
|
5602
|
+
task_uid: { type: 'string', pattern: '^[a-z0-9]{8}$' } } } },
|
|
5603
|
+
];
|
|
5604
|
+
}
|
|
5605
|
+
|
|
5606
|
+
/** Request validation is transport-only. Current identity, source access and selected revision belong to the API. */
|
|
5607
|
+
function taskReadRequest(name, args) {
|
|
5608
|
+
if (!args || typeof args !== 'object' || Array.isArray(args) || typeof args.tenant !== 'string'
|
|
5609
|
+
|| !args.tenant.trim() || [...args.tenant].length > 100) throw new Error('조직 slug를 확인하세요.');
|
|
5610
|
+
const allowed = name === 'get_task_result' ? ['tenant', 'task_uid'] : ['tenant', 'limit', 'offset'];
|
|
5611
|
+
if (Object.keys(args).some(key => !allowed.includes(key))) throw new Error('지원하지 않는 조회 인자입니다.');
|
|
5612
|
+
const query = new URLSearchParams({ tenant: args.tenant });
|
|
5613
|
+
if (name === 'get_task_result') {
|
|
5614
|
+
if (typeof args.task_uid !== 'string' || !/^[a-z0-9]{8}$/.test(args.task_uid)) throw new Error('list_tasks에서 받은 할 일 uid가 필요합니다.');
|
|
5615
|
+
return '/api/work/tasks/' + args.task_uid + '/result?' + query;
|
|
5616
|
+
}
|
|
5617
|
+
const limit = args.limit ?? 20, offset = args.offset ?? 0;
|
|
5618
|
+
if (name !== 'list_tasks' || !Number.isInteger(limit) || limit < 1 || limit > 50
|
|
5619
|
+
|| !Number.isInteger(offset) || offset < 0 || offset > 10000) throw new Error('할 일 목록의 범위를 확인하세요.');
|
|
5620
|
+
return '/api/work/tasks?' + query + '&limit=' + limit + '&offset=' + offset;
|
|
5621
|
+
}
|
|
5622
|
+
|
|
5623
|
+
function taskReadResult(name, args, body) {
|
|
5624
|
+
const pick = (value, keys) => Object.fromEntries(keys.map(key => [key, value[key]]));
|
|
5625
|
+
const hash = value => typeof value === 'string' && /^[a-f0-9]{64}$/.test(value);
|
|
5626
|
+
if (body?.ok !== true) throw new Error('현재 업무 결과를 확인하지 못했습니다.');
|
|
5627
|
+
let result;
|
|
5628
|
+
if (name === 'get_task_result') {
|
|
5629
|
+
const value = body.result, doc = value?.document;
|
|
5630
|
+
if (value?.tenant !== args.tenant || value?.task_uid !== args.task_uid || !hash(value.task_revision)
|
|
5631
|
+
|| !Number.isInteger(value.generation) || value.generation < 0 || value.generation > 10000
|
|
5632
|
+
|| typeof value.basis_changed !== 'boolean' || typeof value.note !== 'string'
|
|
5633
|
+
|| (value.generation === 0) !== (doc === null)) throw new Error('채택 기록의 정체와 판을 확인하지 못했습니다.');
|
|
5634
|
+
if (doc !== null && (!doc || !Number.isInteger(doc.id) || doc.id < 1 || typeof doc.title !== 'string'
|
|
5635
|
+
|| typeof doc.url !== 'string' || !hash(doc.revision) || typeof doc.text !== 'string' || Buffer.byteLength(doc.text) > 65536)) {
|
|
5636
|
+
throw new Error('채택 문서의 전체 본문을 확인하지 못했습니다.');
|
|
5637
|
+
}
|
|
5638
|
+
result = pick(value, ['tenant', 'task_uid', 'task_revision', 'generation', 'basis_changed', 'note']);
|
|
5639
|
+
result.document = doc === null ? null : pick(doc, ['id', 'title', 'url', 'revision', 'text']);
|
|
5640
|
+
} else {
|
|
5641
|
+
const rows = body.tasks, page = body.page, limit = args.limit ?? 20, offset = args.offset ?? 0;
|
|
5642
|
+
const expected = Math.min(limit, Math.max(0, body.open - offset)), end = offset + expected;
|
|
5643
|
+
const next = end < body.open && end <= 10000 ? end : null;
|
|
5644
|
+
if (name !== 'list_tasks' || body.tenant_slug !== args.tenant || !Array.isArray(rows) || !Number.isInteger(body.open)
|
|
5645
|
+
|| body.open < 0 || !page || page.limit !== limit || page.offset !== offset || rows.length !== expected
|
|
5646
|
+
|| page.returned !== expected || page.truncated !== (end < body.open) || page.next_offset !== next) {
|
|
5647
|
+
throw new Error('할 일 목록의 범위를 확인하지 못했습니다.');
|
|
5648
|
+
}
|
|
5649
|
+
const tasks = rows.map(row => {
|
|
5650
|
+
if (!row || typeof row.uid !== 'string' || !/^[a-z0-9]{8}$/.test(row.uid)
|
|
5651
|
+
|| ['title', 'state', 'state_word', 'done_when'].some(key => typeof row[key] !== 'string')) throw new Error('할 일의 내용을 확인하지 못했습니다.');
|
|
5652
|
+
return pick(row, ['uid', 'title', 'state', 'state_word', 'done_when']);
|
|
5653
|
+
});
|
|
5654
|
+
result = { tenant: args.tenant, tasks, total: body.open, page: pick(page, ['limit', 'offset', 'returned', 'truncated', 'next_offset']) };
|
|
5655
|
+
}
|
|
5656
|
+
if (Buffer.byteLength(JSON.stringify(result)) > 98304) throw new Error('현재 업무 결과의 응답 크기가 상한을 넘습니다.');
|
|
5657
|
+
return result;
|
|
5658
|
+
}
|
|
5659
|
+
|
|
5660
|
+
/**
|
|
5661
|
+
* 할 일에 **완료 보고**를 올리는 문(M1 · 2026-09-26) — 사람이 화면에서 쓰는 것과 **같은 서버 함수**(`work_task_do_report`)다.
|
|
5662
|
+
*
|
|
5663
|
+
* 규율(DESIGN-work-tasks.md §4·§7): 세포가 done 을 올리면 완료가 아니라 **검수**다 — 도장은 사람 검수자가 찍는다. 글은 근거가
|
|
5664
|
+
* 아니다 — 근거는 (kind, ref) 튜플이고, 안 주면 **이 세션**을 근거로 싣는다(세션은 서버가 실재를 대조할 수 있는 갈래다).
|
|
5665
|
+
* 응답은 서버의 문장을 그대로 나른다 — 「완료로 표시했습니다」는 어느 갈래에도 없다(G18).
|
|
5666
|
+
*/
|
|
5667
|
+
function taskWriteTools() {
|
|
5668
|
+
const tenant = { type: 'string', minLength: 1, maxLength: 100, description: 'list_my_tenants에서 확인한 조직 slug' };
|
|
5669
|
+
return [
|
|
5670
|
+
{ name: 'report_task',
|
|
5671
|
+
description: '할 일에 완료 보고(또는 대화)를 남깁니다 — 무엇을 어떻게 했는지 본문으로, 확인할 수 있는 근거를 (kind, ref) 로. '
|
|
5672
|
+
+ 'kind 가 done(기본)이면 그 할 일은 검수로 올라가고 완료 도장은 사람이 찍습니다. 근거를 안 주면 이 세션을 근거로 싣습니다'
|
|
5673
|
+
+ '(세션·커밋 같은 내부층 근거는 우리 팀 자격만 — member 자격이면 근거 없이 글만 남고 그렇게 말합니다). '
|
|
5674
|
+
+ '할 일 uid 는 list_tasks 에서 받습니다.',
|
|
5675
|
+
inputSchema: { type: 'object', additionalProperties: false, required: ['tenant', 'task_uid', 'body'], properties: { tenant,
|
|
5676
|
+
task_uid: { type: 'string', pattern: '^[a-z0-9]{8}$', description: 'list_tasks 가 준 할 일 uid' },
|
|
5677
|
+
body: { type: 'string', minLength: 1, maxLength: 4000, description: '무엇을 어떻게 했나 — 사람이 읽는 글. 근거를 대신하지 않는다' },
|
|
5678
|
+
kind: { type: 'string', enum: ['done', 'talk'], default: 'done', description: 'done = 완료 보고(검수로 올림) · talk = 대화만' },
|
|
5679
|
+
evidence: { type: 'array', maxItems: 10, description: '확인할 수 있는 근거. 비우면 {kind:"session", ref:<이 세션>} 하나',
|
|
5680
|
+
items: { type: 'object', additionalProperties: false, required: ['kind', 'ref'], properties: {
|
|
5681
|
+
kind: { type: 'string', enum: ['session', 'commit', 'file', 'case', 'report', 'decision'] },
|
|
5682
|
+
ref: { type: 'string', minLength: 1, maxLength: 300, description: '세션 uuid · 커밋 해시 · 파일 id · cases/…md · reports/<id>#<uid> · decisions/…md' },
|
|
5683
|
+
note: { type: 'string', maxLength: 300 } } } },
|
|
5684
|
+
done_when: { type: 'string', maxLength: 500, description: '완료 조건 — 그 할 일에 아직 없을 때만 채워진다' } } } },
|
|
5685
|
+
];
|
|
5686
|
+
}
|
|
5687
|
+
|
|
5688
|
+
/** 요청 다듬기 — 모양만. 자격·소속처·세션의 실재는 서버가 본다. */
|
|
5689
|
+
function taskWriteRequest(args) {
|
|
5690
|
+
if (!args || typeof args !== 'object' || Array.isArray(args) || typeof args.tenant !== 'string'
|
|
5691
|
+
|| !args.tenant.trim() || [...args.tenant].length > 100) throw new Error('조직 slug를 확인하세요.');
|
|
5692
|
+
if (typeof args.task_uid !== 'string' || !/^[a-z0-9]{8}$/.test(args.task_uid)) throw new Error('list_tasks에서 받은 할 일 uid가 필요합니다.');
|
|
5693
|
+
const body = typeof args.body === 'string' ? args.body.trim() : '';
|
|
5694
|
+
if (!body || [...body].length > 4000) throw new Error('완료 보고 본문은 1~4,000자입니다.');
|
|
5695
|
+
const kind = args.kind === 'talk' ? 'talk' : 'done';
|
|
5696
|
+
const mine = mySessionId();
|
|
5697
|
+
let evidence = Array.isArray(args.evidence) ? args.evidence : [];
|
|
5698
|
+
if (evidence.length > 10) throw new Error('근거는 한 번에 10개까지입니다.');
|
|
5699
|
+
evidence = evidence.map((e) => {
|
|
5700
|
+
if (!e || typeof e !== 'object' || typeof e.kind !== 'string' || typeof e.ref !== 'string' || !e.ref.trim()) throw new Error('근거는 kind 와 ref 가 함께 있어야 합니다.');
|
|
5701
|
+
const out = { kind: e.kind, ref: e.ref.trim().slice(0, 300) };
|
|
5702
|
+
if (typeof e.note === 'string' && e.note.trim()) out.note = e.note.trim().slice(0, 300);
|
|
5703
|
+
return out;
|
|
5704
|
+
});
|
|
5705
|
+
// 근거를 안 줬으면 **이 세션**이 근거다 — 세션 이름을 못 찾으면 지어내지 않고 빈 채로 보낸다(서버가 근거 없음을 말한다).
|
|
5706
|
+
const defaulted = !evidence.length && !!mine;
|
|
5707
|
+
if (defaulted) evidence = [{ kind: 'session', ref: mine }];
|
|
5708
|
+
const req = { tenant: args.tenant, body, kind, evidence, session: mine || '' };
|
|
5709
|
+
if (typeof args.done_when === 'string' && args.done_when.trim()) req.done_when = args.done_when.trim().slice(0, 500);
|
|
5710
|
+
return { path: '/api/work/tasks/' + args.task_uid + '/report', body: req, defaulted };
|
|
5711
|
+
}
|
|
5712
|
+
|
|
5713
|
+
/** 응답 한 줄 — 수와 상태 낱말은 서버의 것, 문장(says)은 그대로 나른다. */
|
|
5714
|
+
function taskWriteResult(uid, r) {
|
|
5715
|
+
const c = r?.comment || {};
|
|
5716
|
+
const t = r?.task || {};
|
|
5717
|
+
const counts = t.evidence_counts || {};
|
|
5718
|
+
const n = Number(counts.total) || (Array.isArray(c.evidence) ? c.evidence.length : 0);
|
|
5719
|
+
const v = Number(counts.usable) || 0;
|
|
5720
|
+
const head = `«${uid}» — ${c.kind_word || '완료 보고'} #${c.id ?? '?'} 올림 · 근거 ${n}(검증 ${v})`;
|
|
5721
|
+
const state = t.state_word ? ` · 상태: ${t.state_word}` : '';
|
|
5722
|
+
return head + state + '.' + (r?.says ? ' ' + r.says : '');
|
|
5723
|
+
}
|
|
5724
|
+
|
|
4007
5725
|
const TOOLS = [
|
|
5726
|
+
...taskReadTools(),
|
|
5727
|
+
...taskWriteTools(),
|
|
4008
5728
|
/*
|
|
4009
5729
|
* ★ **이 도구는 부르지 않아도 일한다.** 설명 자체가 세계 사실을 실어 나른다(`toolsNow()`).
|
|
4010
5730
|
* 도구 목록은 매 턴 모델 문맥에 있으므로, 부르기 전에 이미 눈앞에 놓인다.
|
|
@@ -4169,7 +5889,7 @@ const TOOLS = [
|
|
|
4169
5889
|
'HTML 을 만들지 말 것 — 내용만 주면 서버가 렌더한다.\n' +
|
|
4170
5890
|
'**기본은 초안이다** — 만들어도 고객에게는 보이지 않는다. 눈으로 확인한 뒤 ' +
|
|
4171
5891
|
'release_report 로 열어 준다. 바로 열려면 publish: true 를 명시할 것.\n' +
|
|
4172
|
-
'**문체는
|
|
5892
|
+
'**문체는 주어와 자리가 정한다** — 요청 자리 밖에는 우리·사물·화면이 주어인 문장만 개조식(~함/~했음/~임)으로 쓴다. 독자가 할 일은 요청 자리(open_items·cta·PART I 간지(요청·결정 PART 일 때)·판권지 회신 안내·요청 절 항을 부탁하는 읽는 법 한 줄)에만 두고 사실 문장까지 하십시오체로 맺는다(「회신 부탁드립니다」. 제목은 검수·보고면 「~ 요청」+기한 괄호, 결정 문서의 판단 항목은 물음). 그 밖의 칸에는 사실만 적는다(「기준 수치는 미정」). dry_run 은 일부만 센다.\n' +
|
|
4173
5893
|
'쓰기 전에 결과만 보고 싶으면 dry_run: true — 렌더까지 돌려 경고를 주고 아무것도 저장하지 않는다.',
|
|
4174
5894
|
inputSchema: {
|
|
4175
5895
|
type: 'object',
|
|
@@ -4244,7 +5964,7 @@ const TOOLS = [
|
|
|
4244
5964
|
'**그림 계약**: `assets` 를 **빼고** 보내면 서버가 저장된 그림을 그대로 유지한다. 전부 지우려면 `assets: []` 를 명시하고 ' +
|
|
4245
5965
|
'`sections[].plates`·`chapters[].asset` 에서도 그 id 를 빼야 한다 — 가리키는 곳이 없는 id 는 422 로 거절된다. ' +
|
|
4246
5966
|
'한 장만 바꾸려면 그 id 에 새 `source`/`file` 을 주고 나머지는 get_report 가 준 표식 그대로 두면 된다.\n' +
|
|
4247
|
-
'**문체는
|
|
5967
|
+
'**문체는 주어와 자리가 정한다** — 요청 자리 밖에는 우리·사물·화면이 주어인 문장만 개조식(~함/~했음/~임)으로 쓴다. 독자가 할 일은 요청 자리(open_items·cta·PART I 간지(요청·결정 PART 일 때)·판권지 회신 안내·요청 절 항을 부탁하는 읽는 법 한 줄)에만 두고 사실 문장까지 하십시오체로 맺는다(「회신 부탁드립니다」. 제목은 검수·보고면 「~ 요청」+기한 괄호, 결정 문서의 판단 항목은 물음). 그 밖의 칸에는 사실만 적는다(「기준 수치는 미정」). dry_run 은 일부만 센다.\n',
|
|
4248
5968
|
inputSchema: {
|
|
4249
5969
|
type: 'object',
|
|
4250
5970
|
properties: {
|
|
@@ -4297,11 +6017,12 @@ const TOOLS = [
|
|
|
4297
6017
|
evidence: { type: 'string', description: '근거 — 실측치·커밋 해시·로그 한 줄. 비면 "안 적혔다"로 남는다.' },
|
|
4298
6018
|
tags: { type: 'array', items: { type: 'string' }, description: '갈래(최대 8).' },
|
|
4299
6019
|
paths: { type: 'array', items: { type: 'string' },
|
|
4300
|
-
description: '이 사례가 걸린 **저장소 상대 경로**(예: www/portal/lib/x.php). 본문에 적힌 경로는 자동으로 들어간다.' },
|
|
6020
|
+
description: '이 사례가 걸린 **저장소 상대 파일 경로**(예: www/portal/lib/x.php — 빗금 필수). 본문에 적힌 경로는 자동으로 들어간다. 설비 이름·맨 파일명은 열쇠가 못 된다(세포가 실재 파일 경로와 정확 일치로만 맞힌다) — 서버가 돌려보내며 말한다.' },
|
|
4301
6021
|
commands: { type: 'array', items: { type: 'string' },
|
|
4302
6022
|
description: '이 사례가 걸린 **명령 문구**(예: "INSERT INTO rp_agent_presence"). 다른 세포가 그 문구가 든 명령을 치기 직전에 이 사례를 본다.' },
|
|
4303
6023
|
errors: { type: 'array', items: { type: 'string' },
|
|
4304
|
-
description: '이 사례의 **오류 문자열**(예: "CONSTRAINT ck_tree_machine failed"). 지금은
|
|
6024
|
+
description: '이 사례의 **오류 문자열**(예: "CONSTRAINT ck_tree_machine failed"). 지금은 **관측 쪽**에서만 쓴다 — 사례가 닿은 뒤 같은 오류가 또 났는지를 실패 지문과 대조한다. 도구 직전 알림에는 아직 안 쓴다(세포는 paths·commands 만 맞힌다).' },
|
|
6025
|
+
work: { type: 'string', description: '이 사례가 **어느 일의 시정조치 기록**인지 — 그 일의 열쇠(영문 소문자·숫자·붙임표, 예: hnl-wait). 결정의 work 와 같은 규칙으로 갈래 맨 앞에 접힌다. 부적합(사례) → 조치(일) → 근거(완료)가 한 줄로 이어지게 하는 자리다.' },
|
|
4305
6026
|
picks: { type: 'array', maxItems: 20, items: { type: 'object', required: ['selector', 'text'], properties: {
|
|
4306
6027
|
selector: { type: 'string', description: '페이지에서 다시 잴 자리 — 훅 줄의 sel `…` 그대로' },
|
|
4307
6028
|
text: { type: 'string', description: '사람이 본 글자 — 훅 줄의 "…" 그대로' },
|
|
@@ -4340,7 +6061,9 @@ const TOOLS = [
|
|
|
4340
6061
|
when: { type: 'string', description: '정한 날짜(예: 2026-08-27). 모르면 비운다 — 오늘로 지어내지 말 것.' },
|
|
4341
6062
|
why: { type: 'string', description: '그때의 근거 — 수치·관찰. 이 칸이 이 장의 값이다.' },
|
|
4342
6063
|
reopen: { type: 'string', description: '무슨 조건이면 다시 여나. **비우면 「열린 결정」으로 읽힌다.** 끝났으면 "조건 없음(종결)".' },
|
|
4343
|
-
status: { type: 'string', description: '상태 — "유효"(기본) · "뒤집힘 → decisions
|
|
6064
|
+
status: { type: 'string', description: '상태 — "유효"(기본) · "뒤집힘 → decisions/<이 결정을 뒤집은 장>" · "조건 충족 → <무엇이> (날짜)". ★ 「뒤집힘」은 **옛 결정을 다시 저장할 때** 적는 값이고, **무엇으로 뒤집혔는지** 대야 저장된다 — 경로가 없거나 그 장이 실재하지 않으면 거절한다(get_case 가 준 경로 그대로). **새 결정이 옛 결정을 뒤집는 것이면 status 가 아니라 overturns 를 쓴다** — 새 결정의 status 에 「뒤집힘」을 적으면 그 새 결정이 폐기로 읽힌다.' },
|
|
6065
|
+
overturns: { type: 'string', description: '이 결정이 **뒤집는 옛 결정**의 경로(decisions/<그 결정>, get_case 가 준 것 그대로). 적으면 서버가 **같은 커밋**에서 옛 결정의 상태를 「뒤집힘 → 이 결정」으로 고치고, 이 결정 본문에 「그 결정을 뒤집는다」를 남긴다(양쪽 연결). 이 결정의 status 는 비워 둔다(유효로 선다). ★ overturns_revision 이 없으면 거절한다 — 읽지 않은 장은 뒤집을 수 없다.' },
|
|
6066
|
+
overturns_revision: { type: 'string', pattern: '^[a-f0-9]{64}$', description: 'overturns 로 댄 옛 결정을 get_case 로 읽은 revision 그대로. 그 사이 누가 그 장을 고쳤으면 409 로 멈춘다 — 다시 읽고 판단을 다시 한다.' },
|
|
4344
6067
|
tags: { type: 'array', items: { type: 'string' }, description: '갈래(최대 8). ★ 이것이 있어야 이 결정이 「일」 후보 계산에 들어간다.' },
|
|
4345
6068
|
work: { type: 'string', description: '**이 결정이 곧 과업이면** 그 일의 열쇠(영문 소문자·숫자·붙임표, 예: hnl-wait). 적으면 그 이름의 일이 **지금** 「계획」으로 선다 — 착수 전에 세우는 정문이다. 이미 열린 일의 열쇠면 이 결정이 그 일의 **항목**(작업 리스트 한 줄)으로 서고 간트에 저장 즉시 보인다 — 항목 여럿이면 결정을 여러 번 적는다(항목마다 제 근거를 갖는다).' },
|
|
4346
6069
|
order: { type: 'array', items: { type: 'string' }, description: '일 사이 **순서**를 정하는 결정이면 일의 열쇠를 앞선 것부터(최대 24). 표에 저장하지 않고 일 목록이 이 결정을 읽어 그 순서로 선다 — 순서가 바뀌면 새 결정을 적는다.' },
|
|
@@ -4355,10 +6078,34 @@ const TOOLS = [
|
|
|
4355
6078
|
source: { type: 'string', description: '고칠 자리 — 훅 줄의 <저장소 상대 경로>:<줄> 그대로(없으면 비움)' },
|
|
4356
6079
|
url: { type: 'string', description: '그 화면 주소(알면)' },
|
|
4357
6080
|
} }, description: '사용자가 브라우저에서 **집은 요소**(iab 픽) — remember_case 의 같은 칸. 사용자가 "iab N" 이라 했으면 훅 줄의 #N 에서 그대로 옮긴다, 지어내지 않는다. 장에 「집은 자리」 절로 선다(최대 20).' },
|
|
6081
|
+
// 소속처가 반려한 이름(2026-09-26) — 목록의 정본이 이 칸이다. 모양 검사는 창구(wiki_rejected_names_clean)가 한다.
|
|
6082
|
+
rejected_names: { type: 'array', maxItems: 20, items: { type: 'object', required: ['name'], properties: {
|
|
6083
|
+
name: { type: 'string', description: '반려된 이름 — 작업하며 지은 판·환경·인스턴스 이름(공백 빼고 2~40자)' },
|
|
6084
|
+
use: { type: 'string', description: '대신 쓸 원래 이름 — 독자 화면·문서의 이름(80자까지)' },
|
|
6085
|
+
near: { type: 'array', maxItems: 8, items: { type: 'string' }, description: '흔한 낱말이라 문맥으로만 가를 이름이면 함께 설 낱말(각 20자까지) — 그 낱말이 같은 문장 12자 안에 설 때만 센다. 정규식은 받지 않는다' },
|
|
6086
|
+
} }, description: '**반려가 작업하며 지은 이름을 짚었으면** 그 이름과 대신 쓸 원래 이름(최대 20). 이 소속처의 문서 발행·모의 발행·로컬 렌더가 경고로 센다(list_rejected_names 로 확인). 반려 날짜와 까닭은 when·why 에 적는다. 같은 제목으로 다시 적으면 목록도 통째로 바뀐다 — 빠진 이름은 응답이 말한다.' },
|
|
4358
6087
|
},
|
|
4359
6088
|
required: ['tenant', 'title', 'what', 'commit_id'],
|
|
4360
6089
|
},
|
|
4361
6090
|
},
|
|
6091
|
+
{
|
|
6092
|
+
// 소속처가 반려한 이름(2026-09-26) — 로컬 렌더(render.py)·발행 직전 훅이 서버를 안 거치고도 발행과 같은 목록으로 세게 하는 창구.
|
|
6093
|
+
// 원격(/mcp)에는 두지 않는다 — 그쪽은 렌더가 서버에서 돌아 소비자가 없다.
|
|
6094
|
+
name: 'list_rejected_names',
|
|
6095
|
+
description:
|
|
6096
|
+
'**이 소속처가 반려한 이름 목록**을 읽는다 — 결정 장(remember_decision 의 rejected_names 칸)에서 이름마다 가장 새 결정을 모은다(뒤집힌 결정은 뺀다). '
|
|
6097
|
+
+ '문서 발행·모의 발행이 렌더러에 넘겨 경고로 세는 **그 목록**이다. 읽기만 한다.\n'
|
|
6098
|
+
+ '언제 부르는가: 반려 이름을 적은 뒤 확인할 때 · 로컬 렌더(render.py --tenant)와 발행 직전 훅이 부른다(format: "json").\n'
|
|
6099
|
+
+ '0 이면 0 이라고 말하고, 결정 장의 줄이 깨졌으면 그 장을 짚는다.',
|
|
6100
|
+
inputSchema: {
|
|
6101
|
+
type: 'object',
|
|
6102
|
+
properties: {
|
|
6103
|
+
tenant: { type: 'string', description: '대상 소속처 slug(이름도 받는다 — list_my_tenants 참조).' },
|
|
6104
|
+
format: { type: 'string', enum: ['text', 'json'], description: '"json" 이면 기계가 읽을 JSON 한 덩어리({tenant, tenant_slug, names:[{name,use,near}], counted, skipped, note}). 기본은 사람이 읽을 글.' },
|
|
6105
|
+
},
|
|
6106
|
+
required: ['tenant'],
|
|
6107
|
+
},
|
|
6108
|
+
},
|
|
4362
6109
|
{
|
|
4363
6110
|
name: 'forget_decision',
|
|
4364
6111
|
description:
|
|
@@ -4438,6 +6185,8 @@ const TOOLS = [
|
|
|
4438
6185
|
+ '잘못 표시했으면 work+item+undone:true 로 되돌린다(사후 정정 — 조용히 지워지지 않고 원장에 남는다).\n'
|
|
4439
6186
|
+ '**사람이 화면에서 체크박스로 표시하는 것은 이것의 별도 경로일 뿐, 필수 경로가 아니다** — '
|
|
4440
6187
|
+ '두뇌(에이전트) 스스로 완료를 확인했으면 사람이 누르기를 기다리지 않고 바로 이 도구로 표시한다.\n'
|
|
6188
|
+
+ '**그 일의 항목 번호·제목·계획 기간·상태가 필요하면 work(slug)만 주고 부른다** — 항목 목록이 한 번에 온다'
|
|
6189
|
+
+ '(결정 장을 한 장씩 열어 번호를 찾지 말 것).\n'
|
|
4441
6190
|
+ '요지 전문은 get_case(tenant, "work/<slug>.md") 로 읽는다. **0 이면 0 이라고 말한다.**',
|
|
4442
6191
|
inputSchema: {
|
|
4443
6192
|
type: 'object',
|
|
@@ -4445,7 +6194,7 @@ const TOOLS = [
|
|
|
4445
6194
|
tenant: { type: 'string', description: '대상 소속처 slug(이름도 받는다 — list_my_tenants 참조).' },
|
|
4446
6195
|
claim: { type: 'string', description: '이 일(slug)에 붙겠다고 선언한다 — 잠정이며, 다른 일을 선언하면 앞 선언은 대체된다. 비우면 목록만 읽는다.' },
|
|
4447
6196
|
item: { type: 'string', description: 'claim 과 함께 쓰면 착수 선언(잠정). work 와 함께 쓰면 완료 확인/표시/해제 대상 — 이 일 안의 결정 항목(item_key, 예: decisions/foo.md).' },
|
|
4448
|
-
work: { type: 'string', description: '이 일(slug)
|
|
6197
|
+
work: { type: 'string', description: '이 일(slug). **혼자 주면 그 일의 항목 목록**(번호·제목·계획 기간·상태·item_key)을 읽는다(부작용 없음). item 과 함께 주면 그 항목의 완료 확인·표시·해제 — claim 과 별개(착수 선언 없이도 완료를 다룰 수 있다).' },
|
|
4449
6198
|
done_when: { type: 'string', description: '이 항목의 완료 조건 — 무엇이 되면 끝인가. done_evidence 와 반드시 함께 채운다(work+item과 함께).' },
|
|
4450
6199
|
done_evidence: { type: 'string', description: 'done_when 과 함께 쓴다 — 그 조건이 실제로 충족됐다는 구체적 관측(파일·시험 결과·커밋 등). 비우면 완료로 못 간다 — 지어내지 말 것, 스스로 확인한 것만.' },
|
|
4451
6200
|
undone: { type: 'boolean', description: '이 항목의 완료 표시를 되돌린다(사후 정정) — work+item 과 함께 쓴다.' },
|
|
@@ -4911,13 +6660,16 @@ function worldWho(c) {
|
|
|
4911
6660
|
return '누군가';
|
|
4912
6661
|
}
|
|
4913
6662
|
|
|
4914
|
-
function worldFacts(max = 8) {
|
|
6663
|
+
function worldFacts(max = 8, strict = false) {
|
|
4915
6664
|
const { bin } = cellCheck();
|
|
4916
6665
|
if (!bin) return null; // 세포가 없다 — 「없다」와 구별한다
|
|
4917
6666
|
try {
|
|
4918
6667
|
const r = spawnSync(bin, ['delta', '--root', process.cwd(), '--json'],
|
|
4919
6668
|
{ encoding: 'utf8', timeout: 800 });
|
|
4920
|
-
|
|
6669
|
+
// `strict` 는 알림 기준선(notifyToolsChanged)의 몫이다 — 시간 초과·실패는 「조용함」이 아니라 「못 읽음」이다.
|
|
6670
|
+
// 빈 목록을 기준선으로 삼으면 다음 정상 답이 변화로 읽혀 알림이 한 번 샌다(2026-09-28 게시 관문 notify 대조군 실측).
|
|
6671
|
+
if (r.error || r.status !== 0) return strict ? null : [];
|
|
6672
|
+
if (!r.stdout) return [];
|
|
4921
6673
|
const d = JSON.parse(r.stdout);
|
|
4922
6674
|
const ch = Array.isArray(d.changes) ? d.changes : [];
|
|
4923
6675
|
const out = ch.slice(0, max).map((c) => {
|
|
@@ -5253,7 +7005,7 @@ let toolsNotifyReady = false; // initialize 전에는 안 보낸다
|
|
|
5253
7005
|
let SEND = null;
|
|
5254
7006
|
|
|
5255
7007
|
function notifyToolsChanged(sendFn) {
|
|
5256
|
-
const facts = worldFacts(3);
|
|
7008
|
+
const facts = worldFacts(3, true);
|
|
5257
7009
|
const sig = facts === null ? null : facts.join('|');
|
|
5258
7010
|
if (sig === null) return; // 세포를 못 읽었다 — 지어내지 않는다
|
|
5259
7011
|
if (toolsSig === null) { toolsSig = sig; return; } // 첫 관측은 기준선일 뿐이다
|
|
@@ -5721,6 +7473,38 @@ function requireArgs(name, args) {
|
|
|
5721
7473
|
+ `받는 이름은 이것뿐입니다 — ${all}`);
|
|
5722
7474
|
}
|
|
5723
7475
|
|
|
7476
|
+
/**
|
|
7477
|
+
* ── `works(tenant, work)` — 그 일의 항목 한 줄씩(GET /api/work/items, 2026-09-27) ─────────────
|
|
7478
|
+
*
|
|
7479
|
+
* 오너: 「매번 너한테 일감 항목 번호 가져오라고 하는 것 부터가 문제로보여」. 세포는 항목 번호를 알려고
|
|
7480
|
+
* 결정 장을 한 장씩 열었다(같은 날 hnl 세션: 배정안 + 항목 장 일곱). 서버가 간트와 같은 자로 낸 줄을
|
|
7481
|
+
* 그대로 옮긴다 — 번호(code)는 제목 머리의 [ … ] 이고, 없으면 제목만 쓴다(지어내지 않는다).
|
|
7482
|
+
* ⚠ **서버가 준 칸만 쓴다** — 계획·상태 칸이 없거나 비면 그 토막을 안 그린다(옛 서버·빈 관측).
|
|
7483
|
+
*/
|
|
7484
|
+
function worksItemsList(r) {
|
|
7485
|
+
const works = Array.isArray(r?.works) ? r.works : [];
|
|
7486
|
+
if (!works.length) return `${r?.tenant || ''} — 열린 일이 없다. 0건은 0건이다.`;
|
|
7487
|
+
const md = (d) => String(d || '').slice(5); // 2026-09-14 → 09-14
|
|
7488
|
+
const out = [];
|
|
7489
|
+
for (const w of works) {
|
|
7490
|
+
const items = Array.isArray(w?.items) ? w.items : [];
|
|
7491
|
+
out.push(`«${w.name || w.slug}» (${w.slug}) — 항목 ${items.length}개`);
|
|
7492
|
+
for (const it of items) {
|
|
7493
|
+
const code = typeof it.code === 'string' && it.code !== '' ? it.code : '';
|
|
7494
|
+
const title = code ? String(it.title || '').replace(/^\[[^\]]*\]\s*/, '') : String(it.title || '');
|
|
7495
|
+
// 계획 끝 말(plan_word)·고유 번호(uid)도 서버가 준 것만 — 할 일 화면과 같은 자(work_task_due_word) · 할 일 표의 uid.
|
|
7496
|
+
const due = it.plan_word && it.plan_word.text && it.plan_word.text !== '—' ? ` (${it.plan_word.text})` : '';
|
|
7497
|
+
const plan = it.plan && it.plan.from ? ` · 계획 ${md(it.plan.from)}~${md(it.plan.to)}${due}` : '';
|
|
7498
|
+
const state = it.state_word ? ` · ${it.state_word}` : '';
|
|
7499
|
+
const uid = typeof it.uid === 'string' && it.uid !== '' ? ` · uid ${it.uid}` : '';
|
|
7500
|
+
out.push(` · ${code ? code + ' ' : ''}${title}${plan}${state} — ${it.key}${uid}`);
|
|
7501
|
+
}
|
|
7502
|
+
if (Number(w.dropped) > 0) out.push(` (지시형 제목이라 뺀 결정 ${Number(w.dropped)}건)`);
|
|
7503
|
+
}
|
|
7504
|
+
out.push('항목을 건드리기 전에는 works(tenant, work, item) 로 이미 끝났는지 먼저 확인한다 — 전문은 get_case(tenant, item_key).');
|
|
7505
|
+
return out.join('\n');
|
|
7506
|
+
}
|
|
7507
|
+
|
|
5724
7508
|
/*
|
|
5725
7509
|
* ── `works` 한 줄의 「항목」 토막 (DESIGN-genetics-ssot.md §7.7) ───────────────────────────
|
|
5726
7510
|
*
|
|
@@ -5749,6 +7533,36 @@ function worksItemsLine(items) {
|
|
|
5749
7533
|
return rows.length ? `${head} · 그중 ${rows.join(' · ')}` : head;
|
|
5750
7534
|
}
|
|
5751
7535
|
|
|
7536
|
+
/**
|
|
7537
|
+
* **목록 밖으로 떨어진 결정**(2026-09-21) — 그 일의 갈래와 만나는데 **항목이 못 된** 장.
|
|
7538
|
+
*
|
|
7539
|
+
* ★ 왜 이 줄이 있나(LIM 실측 2026-09-21): 「간트에서 항목을 에이전트에게 시키기」 한 목표를
|
|
7540
|
+
* 다룬 결정 5장 중 표지(`work:`)를 단 것은 1장(gantt-dispatch)뿐이었다. 나머지 4장은
|
|
7541
|
+
* **어느 목록에도 어느 수에도 안 섰다** — 그래서 사람도 두뇌도 「왜 이 일에 항목이 이것뿐인가」를
|
|
7542
|
+
* 물을 길이 없었다. 항목으로 세우지는 **않는다**(2026-08-31 오너 판정: 그 일에 **대한** 결정은
|
|
7543
|
+
* 그 일의 **할 일**이 아니다). 빠졌다는 **사실만** 말한다.
|
|
7544
|
+
*
|
|
7545
|
+
* ★ **방어적으로 그린다** — 서버가 이 칸을 안 보내면 **오늘 내는 글자 그대로** 낸다.
|
|
7546
|
+
* 읽는 칸: `unmarked = { no_mark, baked, off_work, total, paths: [<결정 장 경로>, …] }`
|
|
7547
|
+
* 사유마다 **처방이 다르므로** 낱말을 가른다:
|
|
7548
|
+
* · `no_mark` 표지가 없다 → 그 결정에 `work:` 를 달면 항목이 된다
|
|
7549
|
+
* · `baked` 두뇌가 구운 장이다 → 사람이 제 것으로 세워야(`origin: human`) 비로소 읽힌다
|
|
7550
|
+
* · `off_work` 표지가 **열린 일을 안 가리킨다** → 열쇠가 갈렸다(닫혔거나 다른 이름)
|
|
7551
|
+
*/
|
|
7552
|
+
function worksUnmarkedLine(u) {
|
|
7553
|
+
const x = u && typeof u === 'object' ? u : {};
|
|
7554
|
+
const total = Number(x.total) || 0;
|
|
7555
|
+
if (!total) return '';
|
|
7556
|
+
const bits = [];
|
|
7557
|
+
if (Number(x.no_mark) > 0) bits.push(`표지 없음 ${Number(x.no_mark)}`);
|
|
7558
|
+
if (Number(x.baked) > 0) bits.push(`두뇌가 구운 장 ${Number(x.baked)}`);
|
|
7559
|
+
if (Number(x.off_work) > 0) bits.push(`표지가 다른 이름 ${Number(x.off_work)}`);
|
|
7560
|
+
if (!bits.length) return '';
|
|
7561
|
+
const paths = (Array.isArray(x.paths) ? x.paths : []).filter((q) => typeof q === 'string' && q).slice(0, 3);
|
|
7562
|
+
return `\n ⚠ 이 일 언저리에서 **항목이 못 된 결정 ${total}건** — ${bits.join(' · ')}.`
|
|
7563
|
+
+ (paths.length ? ` 예: ${paths.join(' · ')}` : '');
|
|
7564
|
+
}
|
|
7565
|
+
|
|
5752
7566
|
async function callTool(name, args = {}) {
|
|
5753
7567
|
requireArgs(name, args);
|
|
5754
7568
|
switch (name) {
|
|
@@ -6107,7 +7921,7 @@ async function callTool(name, args = {}) {
|
|
|
6107
7921
|
return r.says;
|
|
6108
7922
|
}
|
|
6109
7923
|
case 'publish_report': {
|
|
6110
|
-
const data = await materializeAssets(args.data);
|
|
7924
|
+
const data = await materializeAssets(args.data, args.tenant);
|
|
6111
7925
|
// **기본은 초안이다.** 서버 기본값은 참(다른 호출자의 동작을 말없이 바꾸지 않는다)이라
|
|
6112
7926
|
// 여기서 뒤집는다 — 확인 전에 고객이 먼저 보는 일을 막는 것은 도구의 몫이다.
|
|
6113
7927
|
const publish = args.publish === true;
|
|
@@ -6153,7 +7967,7 @@ async function callTool(name, args = {}) {
|
|
|
6153
7967
|
return `${r.note}\n${lines.join('\n')}`;
|
|
6154
7968
|
}
|
|
6155
7969
|
case 'update_report': {
|
|
6156
|
-
const data = await materializeAssets(args.data);
|
|
7970
|
+
const data = await materializeAssets(args.data, args.tenant);
|
|
6157
7971
|
const payload = { ...data, tenant: args.tenant };
|
|
6158
7972
|
// 노출 손잡이는 떼고 보낸다(2026-09-14). get_report 가 돌려주는 data 에는 발행 때 보낸 몸통이
|
|
6159
7973
|
// 그대로 남아 있어(source_json) `publish`·`published_at` 이 메아리로 섞여 온다 — 그 값이
|
|
@@ -6198,12 +8012,53 @@ async function callTool(name, args = {}) {
|
|
|
6198
8012
|
if (!facts.length) return '이 저장소에서 최근에 남이 바꾼 것은 없다.';
|
|
6199
8013
|
return `이 저장소에서 최근에 바뀐 것:\n${facts.map((f) => ` · ${f}`).join('\n')}`;
|
|
6200
8014
|
}
|
|
8015
|
+
case 'list_tasks':
|
|
8016
|
+
case 'get_task_result': {
|
|
8017
|
+
const result = await api('GET', taskReadRequest(name, args));
|
|
8018
|
+
return JSON.stringify(taskReadResult(name, args, result), null, 2);
|
|
8019
|
+
}
|
|
8020
|
+
case 'report_task': {
|
|
8021
|
+
const req = taskWriteRequest(args);
|
|
8022
|
+
const call = async (body) => {
|
|
8023
|
+
try {
|
|
8024
|
+
return taskWriteResult(args.task_uid, await api('POST', req.path, body));
|
|
8025
|
+
} catch (e) {
|
|
8026
|
+
/* 거절이어도 글은 남았을 수 있다(근거가 없어 검수로 못 간 경우) — 서버가 그 글을 400 봉투에 함께 실어 준다. 지어내지 않고 그대로 말한다. */
|
|
8027
|
+
if (e && e.status === 400 && e.why && /^(need_evidence|need_done_when|need_assignee)$/.test(e.why)) {
|
|
8028
|
+
return `«${args.task_uid}» — 글은 남았지만 검수로 못 갔다(${e.why}): ${e.message}`
|
|
8029
|
+
+ (e.comment && e.comment.id ? ` 글은 남았습니다(#${e.comment.id}).` : '');
|
|
8030
|
+
}
|
|
8031
|
+
throw e;
|
|
8032
|
+
}
|
|
8033
|
+
};
|
|
8034
|
+
try {
|
|
8035
|
+
return await call(req.body);
|
|
8036
|
+
} catch (e) {
|
|
8037
|
+
/* 기본 근거(이 세션)는 내부층 갈래라 member 자격에서는 403 inner_only 로 **글도 안 남는다**(2026-09-26 검토). 사용자가 근거를 안 준 판이면
|
|
8038
|
+
근거 없이 한 번 더 보낸다 — 그러면 글은 남고, 검수로 못 간 사실은 위 400 갈래가 말한다. 사용자가 직접 준 근거는 되풀이하지 않는다. */
|
|
8039
|
+
if (e && e.status === 403 && e.why === 'inner_only' && req.defaulted) {
|
|
8040
|
+
const out = await call({ ...req.body, evidence: [] });
|
|
8041
|
+
return out + ' (이 자격으로는 세션을 근거로 못 실어 근거 없이 남겼다 — 내부층 근거는 우리 팀만.)';
|
|
8042
|
+
}
|
|
8043
|
+
throw e;
|
|
8044
|
+
}
|
|
8045
|
+
}
|
|
6201
8046
|
case 'works': {
|
|
6202
8047
|
if (args.work) {
|
|
6203
|
-
// 완료 확인/표시/해제(095) — claim 과 별개 통로. item
|
|
8048
|
+
// 완료 확인/표시/해제(095) — claim 과 별개 통로. 표시·해제는 item 이 필수(어느 결정인지).
|
|
6204
8049
|
const wSlug = String(args.work);
|
|
6205
8050
|
const item = args.item ? String(args.item) : '';
|
|
6206
|
-
if (!item)
|
|
8051
|
+
if (!item) {
|
|
8052
|
+
// work 만 주면 **그 일의 항목 목록**(2026-09-27) — 번호·제목·계획·상태를 한 번에 읽는다.
|
|
8053
|
+
// 오너: 「매번 너한테 일감 항목 번호 가져오라고 하는 것 부터가 문제」 — 결정 장을 한 장씩 열던 자리.
|
|
8054
|
+
if (args.undone || args.done_when || args.done_evidence) {
|
|
8055
|
+
return '완료 표시/해제에는 item(item_key)이 있어야 한다 — work만으로는 어느 결정인지 모른다. '
|
|
8056
|
+
+ '항목 열쇠는 work 만 주고 불러 목록에서 고른다.';
|
|
8057
|
+
}
|
|
8058
|
+
const r = await api('GET', '/api/work/items?tenant=' + encodeURIComponent(args.tenant)
|
|
8059
|
+
+ '&work=' + encodeURIComponent(wSlug));
|
|
8060
|
+
return worksItemsList(r);
|
|
8061
|
+
}
|
|
6207
8062
|
if (args.undone) {
|
|
6208
8063
|
const r = await api('POST', '/api/work/done', { tenant: args.tenant, work: wSlug, item, undone: true });
|
|
6209
8064
|
return `${r.item.key} — 완료 표시를 되돌렸다(사후 정정, 원장에 남는다). 지금 상태: ${r.word}.`;
|
|
@@ -6246,20 +8101,29 @@ async function callTool(name, args = {}) {
|
|
|
6246
8101
|
// 차례를 사람이 정했으면 그 출처를 말한다 — 「…부터 해라」가 아니라 「…이 정한 차례다」(서술형).
|
|
6247
8102
|
if (r.order_from?.title) lines.push(` 차례는 결정 «${r.order_from.title}» 이 정했다 — 위에서부터가 그 차례다.`);
|
|
6248
8103
|
let reportSeen = false;
|
|
8104
|
+
let unmarkedSeen = false;
|
|
6249
8105
|
for (const w of rows) {
|
|
6250
8106
|
if (Array.isArray(w.items?.reports) && w.items.reports.some((x) => Number(x?.id) > 0)) reportSeen = true;
|
|
8107
|
+
if (Number(w.unmarked?.total) > 0) unmarkedSeen = true;
|
|
6251
8108
|
lines.push(` · «${w.name || w.slug}» (${w.slug}) — ${w.word} · 세션 ${w.sessions}`
|
|
6252
8109
|
// 항목 = 그 일을 가리키는 결정, 그리고 공개된 보고서의 열린 항목(§7.7). 「무엇을 하기로 했나」가 몇 건인지.
|
|
6253
8110
|
// 0 이면 안 붙인다. done 은 실행 축 집계(095) — 어느 항목인지는 안 말한다, work+item 으로 따로 확인.
|
|
6254
8111
|
+ worksItemsLine(w.items)
|
|
6255
8112
|
+ (w.stale ? ` · 낡음 ${w.stale}일째(AI 관측 — 활동이 멎었다: 사람이 닫거나 다시 살릴 일)` : '')
|
|
6256
|
-
+ (w.doc ? ` · 요지: get_case(tenant, "${w.doc}")` : ' · 요지 문서 아직 없음')
|
|
8113
|
+
+ (w.doc ? ` · 요지: get_case(tenant, "${w.doc}")` : ' · 요지 문서 아직 없음')
|
|
8114
|
+
+ worksUnmarkedLine(w.unmarked));
|
|
6257
8115
|
}
|
|
6258
8116
|
if (!rows.length) lines.push('열린 일이 없다 — 0건은 0건이다. 착수 전에 세우려면 그 과업의 결정을 remember_decision 으로 남기되 **work 에 일의 열쇠를 적어라** — 저장 응답의 work_note 로 「계획」이 선 것을 확인하고 이 도구를 다시 부른다. 표지 없이 tags 만 달면 신호가 쌓일 때까지 AI 가 기다린다. 군체 화면은 열린 일이 있을 때 소속을 확정하는 자리 — 0건이면 고를 일이 없다.');
|
|
6259
8117
|
else lines.push('붙을 일이 보이면 works(tenant, claim: "<slug>") 로 선언한다 — 확정은 사람이 화면에서 한다.'
|
|
6260
8118
|
+ '\n그 일 안의 특정 결정 항목을 쥐었으면 works(tenant, claim: "<slug>", item: "decisions/<page>.md") 로 함께 선언한다.'
|
|
6261
|
-
+ '\n항목은 그 일의
|
|
8119
|
+
+ '\n항목은 그 일의 열쇠를 **표지(work:)로 단** 결정이다 — 갈래(tags)만 달면 항목이 아니라 「그 일에 **대한** 결정」이라 목록에 안 선다(2026-08-31 오너 판정). 새 항목은 remember_decision 의 work 에 그 열쇠를 적어 남긴다.'
|
|
6262
8120
|
+ '\n항목을 실제로 끝냈으면 works(tenant, work: "<slug>", item: "…", done_when: "…", done_evidence: "…") 로 직접 완료를 표시한다 — 사람이 화면에서 누르길 기다릴 필요 없다.');
|
|
8121
|
+
/* 목록 밖으로 떨어진 결정이 하나라도 보이면 **처방 한 줄**을 더한다(2026-09-21).
|
|
8122
|
+
하나도 없는 소속처의 출력은 오늘과 **한 글자도 다르지 않다** — reportSeen 과 같은 규율. */
|
|
8123
|
+
if (unmarkedSeen) lines.push('⚠ 「항목이 못 된 결정」이 있다 — 그 일에 **대한** 결정이 그 일의 **할 일 목록**에는 안 선 것이다. 사유마다 처방이 다르다:'
|
|
8124
|
+
+ '\n · 표지 없음 — 그 결정을 remember_decision 으로 다시 적을 때 work 에 그 일의 열쇠를 적으면 항목이 된다(같은 제목이면 갱신이라 expected_revision 이 필요하다).'
|
|
8125
|
+
+ '\n · 두뇌가 구운 장 — 사람이 포털에서 제 것으로 세워야 읽힌다. 두뇌가 제 산출물로 제 일을 낳는 길은 **일부러 막혀 있다**.'
|
|
8126
|
+
+ '\n · 표지가 다른 이름 — 같은 목표에 **열쇠가 둘 선 것**이다. 어느 쪽이 정본인지 사람에게 묻고, 새 이름으로 또 열지 마라.');
|
|
6263
8127
|
/*
|
|
6264
8128
|
* ★ 보고서에서 온 항목이 섞여 있을 때만 한 줄 더한다(§7.7) — 그 항목의 **상태는 보고서가 정본**이라
|
|
6265
8129
|
* 여기서 완료를 찍으면 서버가 409 로 막는다. 그 사실을 먼저 말해 두는 것이 헛걸음을 줄인다.
|
|
@@ -6296,6 +8160,10 @@ async function callTool(name, args = {}) {
|
|
|
6296
8160
|
reopen: args.reopen || '', status: args.status || '', tags: args.tags || [],
|
|
6297
8161
|
work: args.work || '', order: args.order || [], plan: args.plan || [],
|
|
6298
8162
|
picks: Array.isArray(args.picks) ? args.picks : [], // 집은 자리(2026-09-03)
|
|
8163
|
+
// 반려 이름(2026-09-26) — 그대로 싣는다. 모양 검사·거절은 창구 하나(wiki_rejected_names_clean)가 한다.
|
|
8164
|
+
rejected_names: Array.isArray(args.rejected_names) ? args.rejected_names : [],
|
|
8165
|
+
// 옛 결정 뒤집기(2026-09-21) — 댈 때만 싣는다(안 대면 요청이 예전과 같다).
|
|
8166
|
+
...(args.overturns ? { overturns: args.overturns, overturns_revision: args.overturns_revision || '' } : {}),
|
|
6299
8167
|
// ★ 출처는 우리가 채운다 — 모델에게 안 묻는다(remember_case 와 같은 규율). 표지가
|
|
6300
8168
|
// 열린 일과 만나 항목으로 설 때 「누가 심었나」(간트의 세션 자국)가 이것이다.
|
|
6301
8169
|
...myCitation(),
|
|
@@ -6306,6 +8174,16 @@ async function callTool(name, args = {}) {
|
|
|
6306
8174
|
? '원문은 저장됐지만 후속 정리가 재시도 한도에 닿았습니다. 운영 확인이 필요합니다.'
|
|
6307
8175
|
: '원문은 저장됐고 후속 정리는 대기 중입니다. 같은 내용을 새로 저장하지 마세요.');
|
|
6308
8176
|
if (Number(r.picks) > 0) lines.push(`집은 자리 ${r.picks}건 실림 — 다른 기계·다른 사람도 그 요소를 본다.`);
|
|
8177
|
+
// 반려 이름(2026-09-26) — 적힌 수와 빠진 이름을 그대로. 칸이 응답에 없으면 옛 서버다(조용히 버려졌다) — 그렇게 말한다.
|
|
8178
|
+
const sentRejected = Array.isArray(args.rejected_names) ? args.rejected_names.length : 0;
|
|
8179
|
+
if (typeof r.rejected_names === 'number') {
|
|
8180
|
+
if (r.rejected_names > 0) lines.push(`반려 이름 ${r.rejected_names}개를 적었다 — 이 소속처의 문서 발행·모의 발행·로컬 렌더가 경고로 센다(확인: list_rejected_names).`);
|
|
8181
|
+
if (Array.isArray(r.rejected_names_dropped) && r.rejected_names_dropped.length) {
|
|
8182
|
+
lines.push(`같은 제목으로 다시 적어 목록에서 빠진 반려 이름: ${r.rejected_names_dropped.join(' · ')} — 이제 세지 않는다. 뺄 뜻이 아니었으면 다시 적을 것.`);
|
|
8183
|
+
}
|
|
8184
|
+
} else if (sentRejected && !r.effects_pending) {
|
|
8185
|
+
lines.push('반려 이름을 보냈지만 이 서버는 아직 그 칸을 모른다 — 판이 낡았다(목록이 안 적혔다).');
|
|
8186
|
+
}
|
|
6309
8187
|
// 안 적은 것을 **그렇게 말한다** — 조용히 열린 결정을 만들거나 계산 밖에 두지 않는다.
|
|
6310
8188
|
if (!args.reopen) {
|
|
6311
8189
|
lines.push('재개 조건을 안 적어 이 결정은 「아직 열린 것」으로 읽힌다. 끝난 결정이면 reopen 에 "조건 없음(종결)" 을 적을 것.');
|
|
@@ -6325,6 +8203,8 @@ async function callTool(name, args = {}) {
|
|
|
6325
8203
|
// 표지(work:)의 앞날 — 문장의 정본은 서버(work_decl_mark_note → 응답 work_note)다.
|
|
6326
8204
|
// 여기서 프로즈를 다시 지으면 두 벌이 되어 한쪽이 낡는다(2026-09-01 에 두 벌을 실제로
|
|
6327
8205
|
// 걷었다 — mcp.php 와 이 파일이 같은 문장을 복제하고 있었다).
|
|
8206
|
+
// 옛 장 도장(2026-09-21) — 문장의 정본은 서버(wiki_decision_overturn_note → 응답 overturn_note)다. 옮기기만 한다.
|
|
8207
|
+
if (typeof r.overturn_note === 'string' && r.overturn_note !== '') lines.push(r.overturn_note);
|
|
6328
8208
|
if (typeof r.work_note === 'string' && r.work_note !== '') {
|
|
6329
8209
|
lines.push(r.work_note);
|
|
6330
8210
|
} else if (r.work_mark) {
|
|
@@ -6336,6 +8216,28 @@ async function callTool(name, args = {}) {
|
|
|
6336
8216
|
}
|
|
6337
8217
|
return lines.join('\n');
|
|
6338
8218
|
}
|
|
8219
|
+
case 'list_rejected_names': {
|
|
8220
|
+
// 고르는 자(이름마다 가장 새 결정 · 뒤집힘 제외 · 깨진 줄 skipped)는 서버 report_rejected_names 하나다 — 여기서 다시 고르지 않는다.
|
|
8221
|
+
const r = await api('GET', '/api/wiki/rejected_names?tenant=' + encodeURIComponent(args.tenant));
|
|
8222
|
+
const names = Array.isArray(r.names) ? r.names : [];
|
|
8223
|
+
const skipped = Array.isArray(r.skipped) ? r.skipped : [];
|
|
8224
|
+
if (args.format === 'json') {
|
|
8225
|
+
// 기계가 읽는 모양 — render.py·발행 직전 훅이 렌더러 cfg `rejected_names` 로 그대로 얹는다(대조에 쓰는 셋만).
|
|
8226
|
+
return JSON.stringify({
|
|
8227
|
+
tenant: r.tenant, tenant_slug: r.tenant_slug,
|
|
8228
|
+
names: names.map((n) => ({ name: n.name, use: n.use || '', near: Array.isArray(n.near) ? n.near : [] })),
|
|
8229
|
+
counted: names.length, skipped, note: r.note || '',
|
|
8230
|
+
});
|
|
8231
|
+
}
|
|
8232
|
+
const lines = [`${r.tenant} — 반려 이름 ${names.length}개(결정 장 ${r.decisions_read ?? 0}장을 읽음)`];
|
|
8233
|
+
for (const n of names) {
|
|
8234
|
+
const near = Array.isArray(n.near) && n.near.length ? ` | 함께: ${n.near.join(', ')}` : '';
|
|
8235
|
+
lines.push(` · ${n.name} → ${n.use || '(대신 쓸 이름 안 적힘)'}${near} (${n.since || '날짜 모름'} · ${n.decision}${n.status && n.status !== '유효' ? ' · ' + n.status : ''})`);
|
|
8236
|
+
}
|
|
8237
|
+
for (const s of skipped) lines.push(` ⚠ ${s.path} — 반려 이름 줄을 못 읽었다(${s.why}). 그 장의 이름은 세지 않는다.`);
|
|
8238
|
+
if (r.note) lines.push(r.note);
|
|
8239
|
+
return lines.join('\n');
|
|
8240
|
+
}
|
|
6339
8241
|
case 'forget_decision': {
|
|
6340
8242
|
const r = await api('DELETE', '/api/wiki/decision?tenant=' + encodeURIComponent(args.tenant)
|
|
6341
8243
|
+ '&path=' + encodeURIComponent(String(args.path || ''))
|
|
@@ -6368,7 +8270,7 @@ async function callTool(name, args = {}) {
|
|
|
6368
8270
|
title: args.title, symptom: args.symptom, cause: args.cause || '', fix: args.fix,
|
|
6369
8271
|
expected_revision: args.expected_revision, commit_id: args.commit_id,
|
|
6370
8272
|
evidence: args.evidence || '', tags: args.tags || [],
|
|
6371
|
-
paths: args.paths || [], commands: args.commands || [], errors: args.errors || [],
|
|
8273
|
+
paths: args.paths || [], commands: args.commands || [], errors: args.errors || [], work: args.work || '',
|
|
6372
8274
|
picks: Array.isArray(args.picks) ? args.picks : [], // 집은 자리(2026-09-03) — 그대로 넘긴다, 모양은 서버가 검사
|
|
6373
8275
|
// ★ **출처는 우리가 채운다 — 모델에게 안 묻는다.** 스키마에 노출하면 지어낸다
|
|
6374
8276
|
// (DESIGN-session-as-vcs.md §5.1 「출처를 도구 인자로 두지 말 것」). 모르면 안 보내고, 서버는 안 적는다.
|
|
@@ -6393,6 +8295,12 @@ async function callTool(name, args = {}) {
|
|
|
6393
8295
|
}
|
|
6394
8296
|
if (Array.isArray(r.skipped) && r.skipped.length) lines.push('못 보낸 곳: ' + r.skipped.join(' / '));
|
|
6395
8297
|
}
|
|
8298
|
+
// 일의 열쇠 — **적혔는지 부른 쪽이 알아야 한다.** 옛 서버는 이 칸 자체가 없어 조용히 버리므로 그것을 그대로 말한다.
|
|
8299
|
+
if (args.work) {
|
|
8300
|
+
if (r.work === undefined) lines.push(`⚠ work=「${args.work}」 는 **이 서버가 모르는 칸**이라 안 적혔다 — 서버를 먼저 올린 뒤 다시 적는다.`);
|
|
8301
|
+
else if (!r.work) lines.push(`⚠ work=「${args.work}」 가 안 적혔다 — 서버가 받아들이지 않았다.`);
|
|
8302
|
+
else lines.push(`일: ${r.work} — 이 사례가 그 일의 시정조치 기록으로 걸렸다(갈래 맨 앞).`);
|
|
8303
|
+
}
|
|
6396
8304
|
if (r.triggers && typeof r.triggers === 'object') {
|
|
6397
8305
|
const t = r.triggers;
|
|
6398
8306
|
const n = (t.paths?.length || 0) + (t.commands?.length || 0) + (t.errors?.length || 0);
|
|
@@ -6548,8 +8456,46 @@ async function callTool(name, args = {}) {
|
|
|
6548
8456
|
+ (follow.length ? `\n\n${follow.join('\n')}` : '');
|
|
6549
8457
|
}
|
|
6550
8458
|
case 'push_session': {
|
|
6551
|
-
|
|
6552
|
-
|
|
8459
|
+
/*
|
|
8460
|
+
* ★ **사람의 올리기도 입구 임대를 지난다 — 간격은 안 지난다**(DESIGN-session-delta-sync §3.4 · C7). 같은 대화를 이 기계의
|
|
8461
|
+
* 다른 창이 지금 올리고 있으면 **비동기로** 최대 30초 기다린다(`Atomics.wait` 는 stdio 고리를 통째로 멈춰 안 쓴다 — race S5).
|
|
8462
|
+
* 정합성은 어차피 서버가 지킨다 — 기다리는 것은 헛 409 를 줄이는 효율 장치라, 임대 자리를 못 만들면 임대 없이 간다.
|
|
8463
|
+
* dry_run 은 대응표·키를 안 만들고 서버도 행을 안 써 임대를 안 탄다.
|
|
8464
|
+
*/
|
|
8465
|
+
let lease = null;
|
|
8466
|
+
if (!args.dry_run) {
|
|
8467
|
+
const got = await leaseWait(String(args.uuid || ''));
|
|
8468
|
+
if (got.held) {
|
|
8469
|
+
return `다른 창이 올리는 중입니다 — 이 기계의 다른 창이 이 대화(${String(args.uuid).slice(0, 8)})를 지금 올리고 있어 `
|
|
8470
|
+
+ `${Math.round(HUMAN_LEASE_WAIT_MS / 1000)}초를 기다렸지만 끝나지 않았습니다. 잠시 뒤 다시 부르세요.`;
|
|
8471
|
+
}
|
|
8472
|
+
lease = got.h || null;
|
|
8473
|
+
}
|
|
8474
|
+
let pushed;
|
|
8475
|
+
try {
|
|
8476
|
+
/*
|
|
8477
|
+
* ★ **사람의 올리기도 덧붙임 길을 탄다**(§3.4 · C8) — 대응표가 서 있고 서버가 광고했으면 덧붙은 줄만 간다.
|
|
8478
|
+
* ⚠ 사람의 push 는 **FULL 예산을 세지도 않고 받지도 않는다**(`hooks.plan` 을 안 건다) — 예산은 기계의 폭주를
|
|
8479
|
+
* 막는 것이지 사람이 「지금 올려라」고 한 것을 미루는 장치가 아니다(C7 이 남긴 숙제 ②의 처방).
|
|
8480
|
+
* ⚠ 따라잡기도 여기서 돈다 — 옆 창이 더 뒤까지 올린 직후에 사람이 보던 409 `behind` 가 이 커밋에서 닫힌다(C6 넘김).
|
|
8481
|
+
*/
|
|
8482
|
+
pushed = await pushSession(args.tenant, args.uuid, args.title, args.dry_run, 'propose', null,
|
|
8483
|
+
lease ? { progress: () => leaseTouch(lease) } : null);
|
|
8484
|
+
// 사람이 올린 시각도 간격의 시작이다 — 방금 올린 대화를 옆 창의 자동 길이 곧바로 또 보내지 않게(색인은 기계 공용).
|
|
8485
|
+
if (!args.dry_run) indexPatch(String(args.uuid), { auto: { last_at: Date.now() } });
|
|
8486
|
+
} catch (e) {
|
|
8487
|
+
// 따라잡고 보니 보낼 줄이 없었다 — 오류가 아니라 사실이다(대응표는 섰다).
|
|
8488
|
+
if (String(e?.code || '') === 'caught_up') {
|
|
8489
|
+
return `올릴 것이 없습니다 — 서버가 이미 이 대화(${String(args.uuid).slice(0, 8)})의 끝까지 가지고 있습니다.`
|
|
8490
|
+
+ (e?.moved && !args.dry_run ? '\n이 기계의 좌표만 맞췄습니다(다음 갱신부터 덧붙은 줄만 올라갑니다).' : '');
|
|
8491
|
+
}
|
|
8492
|
+
throw e;
|
|
8493
|
+
} finally { fileUnlock(lease); }
|
|
8494
|
+
const { r, before, parts, plan, appended } = pushed;
|
|
8495
|
+
// 표시를 **덧붙임 X B / 전체 Y B** 로(§3.4) — 통째로 갔으면 전처럼 「전 → 후」다.
|
|
8496
|
+
const cut = plan?.mode === 'APPEND' || plan?.mode === 'ASK'
|
|
8497
|
+
? `덧붙임 ${Number(appended || 0).toLocaleString()}B / 전체 ${r.bytes.toLocaleString()}B`
|
|
8498
|
+
: `${before.toLocaleString()}B → ${r.bytes.toLocaleString()}B`;
|
|
6553
8499
|
// 폴더 제안의 결과 — 문장의 정본은 서버다(§4.2). 옛 서버는 칸이 없어 침묵한다.
|
|
6554
8500
|
const bindNote = typeof r.binding?.note === 'string' && r.binding.note !== '' ? r.binding.note + '\n' : '';
|
|
6555
8501
|
// 조각으로 갔으면 말한다 — 크기 때문에 사람이 할 일이 없다는 것을 결과가 보여 준다.
|
|
@@ -6972,10 +8918,36 @@ function cellLinkDir(pathEnv, candidates, exists, writable) {
|
|
|
6972
8918
|
}
|
|
6973
8919
|
return null;
|
|
6974
8920
|
}
|
|
8921
|
+
/*
|
|
8922
|
+
* ★★ **남의 HOME 으로 뜬 프로세스는 전역 PATH 자리를 안 건드린다**(C7 · 2026-09-21 실측 누수).
|
|
8923
|
+
* `CELL_PATH` 는 `os.homedir()` 를 따른다(posix 는 HOME 환경). 시험이 가짜 HOME 으로 MCP 를 띄우면 그 HOME 의 세포가
|
|
8924
|
+
* 생기는데, 전역 자리(/opt/homebrew/bin 등)의 `gcell` 이 「옛 자리를 가리키는 우리 심링크」로 읽혀 **가짜 HOME 쪽으로
|
|
8925
|
+
* 갈아 끼워졌다** — 시험이 끝나 그 HOME 이 지워지면 이 기계의 `gcell` 이 통째로 dangling 이 된다(C6 검토 때 실제로 났다).
|
|
8926
|
+
* 그래서: 고른 자리가 이 HOME 밖(전역)이고, 이 프로세스의 HOME 이 **이 사용자의 실제 집**(passwd — `os.userInfo().homedir`)이
|
|
8927
|
+
* 아니면 걸지도 걷지도 않고 말만 한다. HOME 안의 자리(~/.local/bin)는 그 HOME 의 것이라 그대로 건다.
|
|
8928
|
+
* 실제 집을 모르면(userInfo 가 던짐) 닫는 쪽 — 전역 자리를 안 건드린다. **순수 함수**(smoke 가 표로 잰다).
|
|
8929
|
+
*/
|
|
8930
|
+
function cellLinkForeign(dir, home, realHome) {
|
|
8931
|
+
const norm = (p) => { const s = String(p || ''); try { return fs.realpathSync(s); } catch { return s; } };
|
|
8932
|
+
const h = norm(home);
|
|
8933
|
+
const d = norm(dir);
|
|
8934
|
+
if (h && (d === h || d.startsWith(h.endsWith(path.sep) ? h : h + path.sep))) return false; // 이 HOME 의 자리
|
|
8935
|
+
return !realHome || norm(realHome) !== h;
|
|
8936
|
+
}
|
|
8937
|
+
function cellRealHome() {
|
|
8938
|
+
try { return os.userInfo().homedir || ''; } catch { return ''; }
|
|
8939
|
+
}
|
|
6975
8940
|
function cellLinkOnPath() {
|
|
6976
8941
|
const dir = cellLinkDir(process.env.PATH, CELL_LINK_CANDIDATES,
|
|
6977
8942
|
(d) => { try { return fs.statSync(d).isDirectory(); } catch { return false; } },
|
|
6978
8943
|
(d) => { try { fs.accessSync(d, fs.constants.W_OK); return true; } catch { return false; } });
|
|
8944
|
+
if (dir && cellLinkForeign(dir, os.homedir(), cellRealHome())) {
|
|
8945
|
+
// ⚠ 까닭을 **그대로** 적는다(반박 compat 2-B) — 「시험용 HOME」이라고만 하면 `sudo -E`·passwd 항목이 없는 컨테이너처럼
|
|
8946
|
+
// HOME 이 정당하게 다른 사람이 원인을 못 찾는다. 그 사람들은 이 한 줄을 보고 손으로 걸면 된다.
|
|
8947
|
+
return `전역 자리(${dir})에는 \`gcell\` 을 안 걸었다 — 이 프로세스의 HOME(${os.homedir()})이 passwd 의 집`
|
|
8948
|
+
+ `(${cellRealHome() || '알 수 없음'})과 달라, 남의 HOME 으로 뜬 프로세스가 기계 공용 자리를 갈아 끼우지 않게 막았다`
|
|
8949
|
+
+ `(시험용 HOME·sudo -E·컨테이너). 전체 경로 ${CELL_PATH} 로 부르거나, 직접 걸려면 ln -sfn ${CELL_PATH} ${path.join(dir, 'gcell')}.`;
|
|
8950
|
+
}
|
|
6979
8951
|
if (!dir) {
|
|
6980
8952
|
if (process.platform === 'win32') {
|
|
6981
8953
|
return `PATH 에 안 걸었다(Windows 는 심링크에 권한이 필요하다) — 전체 경로 ${CELL_PATH} 로 부르거나 `
|
|
@@ -7000,6 +8972,7 @@ function cellLinkOnPath() {
|
|
|
7000
8972
|
function cellUnlinkOnPath() {
|
|
7001
8973
|
const out = [];
|
|
7002
8974
|
for (const d of CELL_LINK_CANDIDATES) {
|
|
8975
|
+
if (cellLinkForeign(d, os.homedir(), cellRealHome())) continue; // 거는 쪽과 같은 가드 — 남의 HOME 은 전역 자리를 안 만진다
|
|
7003
8976
|
const link = path.join(d, 'gcell');
|
|
7004
8977
|
try {
|
|
7005
8978
|
if (fs.lstatSync(link).isSymbolicLink() && fs.readlinkSync(link) === CELL_PATH) {
|
|
@@ -7438,7 +9411,8 @@ if (IS_MAIN) {
|
|
|
7438
9411
|
}
|
|
7439
9412
|
|
|
7440
9413
|
// 시험용 — 서버 동작에는 쓰이지 않는다. materializeAssets 만 네트워크를 쓴다.
|
|
7441
|
-
export {
|
|
9414
|
+
export { videoThumbs, taskReadTools, taskReadRequest, taskReadResult,
|
|
9415
|
+
encodeAsset, imageDims, imageHostAllowed, materializeAssets, warnText, IMAGE_HOST_ALLOW, HOST_ALLOW,
|
|
7442
9416
|
scrubTranscript, splitAtLines, PUSH_PART_MAX,
|
|
7443
9417
|
// DESIGN-genetics-ssot.md §3·§5 — 색인·잠금·폴더 identity·알림 문구의 순수/파일 함수(시험이 걷는다)
|
|
7444
9418
|
jsonFileUpdate, jsonCorruptAside, credWrite, pendingWrite, pendingClear, indexRead, indexNote, indexPatch, indexTenant,
|
|
@@ -7458,4 +9432,16 @@ export { encodeAsset, imageDims, imageHostAllowed, materializeAssets, warnText,
|
|
|
7458
9432
|
autoRowOnServer,
|
|
7459
9433
|
// 2026-09-18 Windows — 남의 규칙(Claude Code 슬러그)·플랫폼 갈래는 시험이 표로 걷는다
|
|
7460
9434
|
transcriptSlug, transcriptDir, cursorUserRoot, sqliteBin, npxCacheResetHint, cellPlatformSupported,
|
|
7461
|
-
HOLDS_FILE, UPDATE_BUDGET_MS
|
|
9435
|
+
HOLDS_FILE, UPDATE_BUDGET_MS,
|
|
9436
|
+
// DESIGN-session-delta-sync C6 — 규칙판·좌표·기계 키·대응표·머리 끊기·private 판정(시험이 걷는다)
|
|
9437
|
+
SCRUB_REV, CWD_HIST_MAX, PUSH_MAC_WIN, pushDir, pushKey, pushKeyId, pushMac, pushCoords,
|
|
9438
|
+
ledgerRead, ledgerWrite, ledgerAfterFull, autoPrepare, transcriptHead,
|
|
9439
|
+
// DESIGN-session-delta-sync C7 — 한 입구(갈래·임대·임대 안 판정·닫힘) · 요청 상한(바이트에 비례) · 전역 링크 가드(시험이 걷는다)
|
|
9440
|
+
autoPolicyOf, autoIntervalOf, autoGateOf, autoBlockedFor, autoLegacyCheck, autoHistOverflow, gitRootAbove,
|
|
9441
|
+
leaseFile, leaseTake, LEASE_QUIET_MS, HUMAN_LEASE_WAIT_MS, API_TIMEOUT_MS, apiTimeoutFor, API_MIN_BPS, AUTO_CLOSED_CODES,
|
|
9442
|
+
AUTO_BACKOFF_LONG_MS, AUTO_PUSH_MIN_MS, cellLinkForeign,
|
|
9443
|
+
// DESIGN-session-delta-sync C8 — 갈래 고르기·능력 광고·조각 나누기·접기·FULL 예산·사본 재정의(시험이 걷는다)
|
|
9444
|
+
planPush, pushSession, pushAppend, capsNote, capsAppend, capsScrubRev, capsFile, capsAppendGone, CAPS_FALSE_TTL_MS,
|
|
9445
|
+
readAt, lastNewlineAt, countLines, appendScan, splitSeg, foldHist, scrubDelta, hashRange, pushTenantId,
|
|
9446
|
+
APPEND_ENV_MAX, APPEND_RAW_STEP, APPEND_ROUND_MAX,
|
|
9447
|
+
autoFullBudgetOf, autoFullCounts, autoDupOf, PUSH_FULL_BUDGET, PUSH_FULL_WINDOW_MS, PUSH_FULL_REBASE, PUSH_FULL_LOG_MAX };
|