Skip to content

Commit d848525

Browse files
jay-swkclaude
andcommitted
perf(ingest): 2층 캐시로 /api/ingest 44s → 4.3s (10배), 휴면 시 62ms
사용자 발견: Today 화면 새로고침 시 새 세션 안 보임 + refresh=1 44초 대기. 측정 분해 결과 collectGitEvidence(30 sessions × 3 git spawn = 6s)가 dominant. L1 — ingest.json에 inputs(sources/manual/links/reviews/githubActivity mtime) 메타. 모두 동일 시 cached 반환 + refreshAuditChain만 응답 직전 신선 빌드. events.jsonl mtime은 기준에서 제외(auditChain 14ms로 별도). L2 — .awm/parse-cache.json에 path→{mtime,bytes,index,sessions[]} 보관. parseSessionFile 호출 전 4중 비교 hit 시 git evidence 3 spawn 회피. Claude/Codex 내부 활동으로 4/30 jsonl이 매번 mtime 변경되어 L1은 거의 hit 안 하지만 L2가 26/30 hit해서 cost 대부분 흡수. 결과: server 44s→4.3s (10배), L1 hit 62ms (700배), CLI warm 0.56s. Evaluator Medium 2 + Low 2 즉시 해소: - --no-write가 parse-cache 쓰기 안 막던 점 → persist 옵션 - saveParseCache silent fail → console.warn - --force-rebuild help 누락 → printHelp 별도 sprint: L1 miss 시 discovery 이중 호출(82ms), 서버 context 1.5s 추가 cost(capture spawn 경합 의심). 검증: node 55/55 (15 신규) + web 67/67 + build + tsc clean. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent a87e435 commit d848525

5 files changed

Lines changed: 489 additions & 14 deletions

File tree

NOVA-STATE.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,7 @@
6969
||||
7070

7171
## Last Activity
72+
- Ingest Incremental Cache — 2층 캐시(L1 inputs mtime + L2 file-level parse cache) → PASS(qa + Evaluator). 사용자 발견 *"Today 화면 새로고침 시 새 세션 안 보임 + refresh=1은 44초 대기"* → 옵션 3(근본). **측정 분해**: discovery scan 41ms (병목 X) / 30 files JSONL read+parse 56ms / collectGitEvidence 30 sessions × 3 spawn = **6초 dominant** / auditChain build 14ms / *서버 context*가 CLI보다 1.5s 추가(capture hook spawn 경합 의심). **2층 설계**: L1 — ingest.json에 `inputs` 메타(sources/manual/links/reviews/githubActivity mtime) 박고 currentInputs와 비교하여 모두 동일 시 cached 반환 + `refreshAuditChain`만 응답 직전 신선 빌드 / L2 — `.awm/parse-cache.json`에 path→{mtime, bytes, index, sessions[]} 보관, parseSessionFile 호출 직전 cache lookup, 4중 비교(mtime+bytes+index+path) hit 시 git evidence 3 spawn 회피. **events.jsonl mtime은 L1 비교 기준에서 제외** — auditChain만 영향이라 별도 신선화 14ms. **핵심 통찰**: Claude/Codex 내부 활동으로 4/30 jsonl 파일 mtime 매번 변경되어 L1은 거의 hit 안 함, 그러나 L2가 26/30 hit해서 cost 대부분 흡수. **결과**: `/api/ingest` 44s → 4.3s (10배), L1 hit 시 62ms (700배), CLI warm 0.56s (13배). **영향 3 파일** (bin/awm.mjs ~150줄 / tests/ingest-cache.test.mjs 15 case / docs Plan+검증). 회귀: node 40→55 + web 67/67 + build + tsc clean. **Evaluator Medium 2건 즉시 해소**: `--no-write`가 parse-cache 쓰기 안 막던 점 → persist 옵션 추가 + saveParseCache 가드 / saveParseCache silent fail → console.warn / `--force-rebuild` help 누락 → printHelp 추가. **별도 sprint**: L1 miss 시 buildDiscoveryResult 이중 호출 82ms (허용 한계) / 서버 context 1.5s 추가 cost (capture spawn 경합 격리 측정 필요). `docs/projects/plans/ingest-incremental-cache.md` + `docs/verifications/ingest-incremental-cache.md`. **PRD §11 H4 5분 first-value 실용성 확보** — 외부 테스터 첫 Today 진입 7s, 이후 활동 중 4s, 휴면 시 62ms. | 2026-05-13
7273
- Tester Onboarding — `npm run init` 한 줄 + `docs/tester-quickstart.html` 단일 HTML 가이드 + `capture install claude --auto-merge` 자동 머지 → PASS(qa + Evaluator, FAIL 2건 즉시 해소). 사용자 지적 *"8단계 onboarding이 외부 테스터에 비현실적"* → 5분 first-value 압축. **영향 7 파일**: (1) `bin/awm.mjs` — `installClaudeHook({autoMerge})` + `autoMergeClaudeSettings(snippet)` 신규 함수, settings.local.json 깊이 머지(eventName별 그룹 append, command+matcher 기준 dedup) + corrupt JSON throw + CLI `--auto-merge` 옵션 (2) `scripts/awm-init.sh` 신규 — Node ≥ 20 check, npm install(node_modules/.package-lock.json 존재 시 skip), build(dist 있으면 skip), capture install --auto-merge, PID 파일 직접 체크 후 serve 백그라운드, 5단계 색깔 출력 + 마지막 안내 박스 (3) `scripts/awm-serve.sh` (기존 그대로) (4) `package.json` — `npm run init` 추가 (5) `docs/tester-quickstart.html` 신규 self-contained 250줄 — 3 단계(init/Claude 재시작/첫 세션) + FAQ 4건(데이터 위치·hook 영향·종료·제거) + 트러블슈팅 5건(Node·포트·audit 비어있음·브라우저·가이드 stale), inline CSS prefers-color-scheme 다크 자동, Toss-style 외부 페이지 톤(.pub-* 정신), §12.3 외부 노출 금지 7항 통과 (6) `CLAUDE.md` Build & Verify — "외부 테스터 / 신규 환경 — 한 줄" 섹션 + 캡처 hook 섹션 --auto-merge primary로 간소화 (7) `README.md` 최상단 Quick Start 5분 박스 + tester-quickstart.html 링크. **검증**: clean state → npm run init 5/5 PASS + PID 출력 + EOF 박스 / 두 번째 init idempotent(install skip + build skip + hooks "5 already present" + serve "이미 실행 중") / curl /api/health ok / node 40/40 + web 67/67 + tsc clean + build 175ms. **Evaluator FAIL 2건 즉시 해소**: FAIL-1 HTML footer "1인 베타" §12.3 위반 → 제거(외부 노출 금지 7항 7번 운영 인원 규모) / FAIL-2 npm install 멱등 로직 오작동(node_modules -nt 비교 역전) → node_modules/.package-lock.json 존재 체크로 변경. Evaluator 미커버: Node < 20 fail path 실제 실행, Windows, 다크 모드 브라우저 렌더. 산출물: `docs/projects/plans/tester-onboarding.md` + `docs/tester-quickstart.html`. **PRD §11 H4 5분 first-value 가설을 받칠 onboarding 인프라 완비** (8단계 → 1 명령 + 1 재시작). | 2026-05-13
7374
- 개발 편의 — `scripts/awm-serve.sh` (start/stop/restart/status/logs) + npm scripts(`serve`, `serve:stop|restart|status|logs`) 추가 → PASS. 사용자 요청 "로컬 확인용 서버 관리 스크립트". PID/log는 `.awm/serve.{pid,log}` (gitignored). start는 dist 없으면 build → ingest --limit 30 → serve --port 5173 백그라운드. stop은 SIGTERM 5회 폴링 후 SIGKILL fallback. 검증: status idle → start(PID 39501 시작) → curl /api/health ok → restart(PID 51389) → stop → status idle → logs 정상. 환경변수 AWM_PORT / AWM_INGEST_LIMIT override 가능. | 2026-05-13
7475
- M0/S3 H2-b 변조 5/5 명령 수준 측정 (데이터 의존성 0, S2 무관 선행 PASS) → PASS. 메인 push 직후 main checkout 상태에서 자율 진행. Plan H2-b 정의 *"events.jsonl 1행 변조 시 verify 명령 5회 반복 5/5 탐지 + 변조 위치 출력"* 충족. **절차**: (1) capture sample ×6 → events.jsonl 6행 + audit chain ok 확인 (2) `/tmp/h2b-baseline.jsonl` 백업 (3) 시나리오 5건 직렬: 필드 수정(index 0 summary) → brokenAt=0 hash mismatch / 레코드 삽입(index 2 fake) → brokenAt=2 prev mismatch(expected 2a1bf93d, got 00000000) / 레코드 삭제(index 1) → brokenAt=1 prev mismatch / 순서 swap(2-3) → brokenAt=2 prev mismatch / hash 직접 조작(index 4 → ff×64) → brokenAt=4 hash mismatch. 매 시나리오마다 baseline 복원→변조→verify→exit=1 확인 (4) 최종 baseline 복원 → `Audit chain OK — 6 events verified.` exit 0. **5/5 모두 정확** + 사유 type(hash mismatch vs prev mismatch) 적절히 분기. S1.5 unit 17 case와 *두 층위*(unit + CLI 통합)에서 변조 탐지. **의미**: H2-b는 S2 dogfooding 데이터 의존성 0이라 *S3 일부를 선행으로 닫음*. H1·H2-a·H3는 S2 1주 후 측정 대기. `docs/verifications/m0-h2b-cmd-tampering.md` 신규 + NOVA-STATE Tasks 분리(S3 H2-b done, S3 H1·H2-a·H3 pending). .awm 측정 잡음 정리 후 working tree clean. | 2026-05-13

bin/awm.mjs

Lines changed: 143 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@ const persistence = createPersistence({ stateDir });
4545
const { atomicWriteJsonSync, readJsonSafe, runQueued, emitEvent } = persistence;
4646
const discoveryPath = join(stateDir, "discovery.json");
4747
const ingestPath = join(stateDir, "ingest.json");
48+
const parseCachePath = join(stateDir, "parse-cache.json");
4849
const linksPath = join(stateDir, "links.json");
4950
const reviewsPath = join(stateDir, "reviews.json");
5051
const githubActivityPath = join(stateDir, "github-activity.json");
@@ -264,7 +265,7 @@ Usage:
264265
awm discover
265266
awm github status --json
266267
awm github sync --since <ISO>
267-
awm ingest --limit 30
268+
awm ingest --limit 30 [--force-rebuild] [--no-write]
268269
awm serve --port 5173
269270
awm session summarize --tool codex --summary "..."
270271
awm today
@@ -716,44 +717,73 @@ function ingestSessions(values = []) {
716717
const limit = Number(readFlag(values, "--limit") ?? 30);
717718
const asJson = values.includes("--json");
718719
const noWrite = values.includes("--no-write");
719-
const result = buildIngestResult(limit);
720+
const forceRebuild = values.includes("--force-rebuild");
721+
let result;
722+
let cacheHit = false;
723+
if (!forceRebuild && existsSync(ingestPath)) {
724+
try {
725+
const cached = JSON.parse(readFileSync(ingestPath, "utf8"));
726+
if (isIngestCacheValid(cached, currentIngestInputs(limit))) {
727+
result = cached;
728+
cacheHit = true;
729+
}
730+
} catch {}
731+
}
732+
if (!result) result = buildIngestResult(limit, { persist: !noWrite });
720733

721-
if (!noWrite) writeFileSync(ingestPath, `${JSON.stringify(result, null, 2)}\n`);
734+
if (!noWrite && !cacheHit) writeFileSync(ingestPath, `${JSON.stringify(result, null, 2)}\n`);
722735

723736
if (asJson) {
724737
console.log(JSON.stringify(result, null, 2));
725738
return;
726739
}
727740

728-
console.log(`AWM Ingest (${localDate(new Date(result.ingestedAt))})`);
741+
console.log(`AWM Ingest (${localDate(new Date(result.ingestedAt))})${cacheHit ? " — cache hit" : ""}`);
729742
console.log(`Sessions: ${result.sessions.length}`);
730743
console.log(`Risks: ${result.riskEvents.length}`);
731744
console.log(`Repositories: ${result.repositories.length}`);
732-
if (!noWrite) console.log(`Ingest written: ${ingestPath}`);
745+
if (!noWrite && !cacheHit) console.log(`Ingest written: ${ingestPath}`);
733746
}
734747

735-
function buildIngestResult(limit = 30) {
748+
function buildIngestResult(limit = 30, { persist = true } = {}) {
736749
const discovery = buildDiscoveryResult({ includeAllFiles: true });
737750
const confirmedLinks = readLinks();
738751
const reviews = readReviews();
739752
const githubActivity = readGitHubActivity();
740753
const github = buildGitHubVisibility(githubActivity);
741-
writeFileSync(
742-
discoveryPath,
743-
`${JSON.stringify({ ...discovery, sources: discovery.sources.map(({ allFiles, ...source }) => source) }, null, 2)}\n`,
744-
);
754+
if (persist) {
755+
writeFileSync(
756+
discoveryPath,
757+
`${JSON.stringify({ ...discovery, sources: discovery.sources.map(({ allFiles, ...source }) => source) }, null, 2)}\n`,
758+
);
759+
}
745760
const files = discovery.sources
746761
.flatMap((source) => source.allFiles.map((file) => ({ ...file, source })))
747762
.filter(isUserVisibleSessionFile)
748763
.sort((a, b) => b.modifiedAt.localeCompare(a.modifiedAt))
749764
.slice(0, limit);
750765

766+
const parseCache = loadParseCache();
767+
const nextParseCache = {};
751768
const discoveredSessions = files
752-
.flatMap((file, index) => parseSessionFile(file, index))
769+
.flatMap((file, index) => {
770+
const cacheKey = `${file.path}`;
771+
const cacheEntry = parseCache[cacheKey];
772+
let sessions;
773+
if (cacheEntry && cacheEntry.mtime === file.modifiedAt && cacheEntry.bytes === file.bytes && cacheEntry.index === index) {
774+
sessions = cacheEntry.sessions;
775+
} else {
776+
sessions = parseSessionFile(file, index);
777+
nextParseCache[cacheKey] = { mtime: file.modifiedAt, bytes: file.bytes, index, sessions };
778+
}
779+
if (cacheEntry && !nextParseCache[cacheKey]) nextParseCache[cacheKey] = cacheEntry;
780+
return sessions;
781+
})
753782
.filter(Boolean)
754783
.map((session) => mergeGitHubActivityIntoSession(session, githubActivity))
755784
.map((session) => applyLinksToSession(session, confirmedLinks[session.id]))
756785
.map((session) => applyReviewToSession(session, reviews[session.id]));
786+
if (persist) saveParseCache(nextParseCache);
757787
const manualSessions = collectFiles(sessionsDir, ".json", 1)
758788
.sort((a, b) => b.modifiedAt.localeCompare(a.modifiedAt))
759789
.slice(0, limit)
@@ -773,9 +803,21 @@ function buildIngestResult(limit = 30) {
773803
const workPackets = buildWorkPackets(sessions);
774804
const auditChain = buildAuditChainView();
775805

806+
const manualFilesForInputs = collectFiles(sessionsDir, ".json", 1);
807+
const inputs = {
808+
limit,
809+
sources: files.map((f) => ({ path: f.path, mtime: f.modifiedAt })),
810+
manual: manualFilesForInputs.map((f) => ({ path: f.path, mtime: f.modifiedAt })),
811+
links: statMtimeOrNull(linksPath),
812+
reviews: statMtimeOrNull(reviewsPath),
813+
githubActivity: statMtimeOrNull(githubActivityPath),
814+
events: statMtimeOrNull(eventsPath),
815+
};
816+
776817
return {
777818
ingestedAt: new Date().toISOString(),
778819
limit,
820+
inputs,
779821
sources: [
780822
...discovery.sources.map((source) => ({
781823
id: source.id,
@@ -2168,9 +2210,7 @@ function serveLocalApp(values = []) {
21682210
if (url.pathname === "/api/mvp" || url.pathname === "/api/ingest") {
21692211
const refresh = url.searchParams.get("refresh") === "1";
21702212
const limit = Number(url.searchParams.get("limit") ?? 30);
2171-
const ingest = refresh || !existsSync(ingestPath)
2172-
? buildAndStoreIngest(limit)
2173-
: JSON.parse(readFileSync(ingestPath, "utf8"));
2213+
const ingest = refresh ? buildAndStoreIngest(limit) : getCachedOrBuildIngest(limit);
21742214
sendJson(response, ingest);
21752215
return;
21762216
}
@@ -2286,6 +2326,95 @@ function buildAndStoreIngest(limit = 30) {
22862326
return ingest;
22872327
}
22882328

2329+
function loadParseCache() {
2330+
if (!existsSync(parseCachePath)) return {};
2331+
try {
2332+
return JSON.parse(readFileSync(parseCachePath, "utf8"));
2333+
} catch {
2334+
return {};
2335+
}
2336+
}
2337+
2338+
function saveParseCache(cache) {
2339+
try {
2340+
writeFileSync(parseCachePath, JSON.stringify(cache));
2341+
} catch (error) {
2342+
const message = error instanceof Error ? error.message : String(error);
2343+
console.warn(`[awm] parse-cache save failed (will rebuild next time): ${message}`);
2344+
}
2345+
}
2346+
2347+
function statMtimeOrNull(filePath) {
2348+
try {
2349+
if (!existsSync(filePath)) return null;
2350+
return statSync(filePath).mtime.toISOString();
2351+
} catch {
2352+
return null;
2353+
}
2354+
}
2355+
2356+
function currentIngestInputs(limit) {
2357+
const discovery = buildDiscoveryResult({ includeAllFiles: true });
2358+
const files = discovery.sources
2359+
.flatMap((source) => source.allFiles.map((file) => ({ ...file, source })))
2360+
.filter(isUserVisibleSessionFile)
2361+
.sort((a, b) => b.modifiedAt.localeCompare(a.modifiedAt))
2362+
.slice(0, limit);
2363+
const manual = collectFiles(sessionsDir, ".json", 1);
2364+
return {
2365+
limit,
2366+
sources: files.map((f) => ({ path: f.path, mtime: f.modifiedAt })),
2367+
manual: manual.map((f) => ({ path: f.path, mtime: f.modifiedAt })),
2368+
links: statMtimeOrNull(linksPath),
2369+
reviews: statMtimeOrNull(reviewsPath),
2370+
githubActivity: statMtimeOrNull(githubActivityPath),
2371+
events: statMtimeOrNull(eventsPath),
2372+
};
2373+
}
2374+
2375+
function isIngestCacheValid(cached, currentInputs) {
2376+
// events.jsonl은 auditChain만 영향. heavy 캐시 무효화 기준에서 제외.
2377+
// auditChain은 응답 직전 신선하게 재생성된다 (refreshAuditChain).
2378+
if (!cached || !cached.inputs) return false;
2379+
if (cached.inputs.limit !== currentInputs.limit) return false;
2380+
if (!sameMtimeList(cached.inputs.sources, currentInputs.sources)) return false;
2381+
if (!sameMtimeList(cached.inputs.manual, currentInputs.manual)) return false;
2382+
if (cached.inputs.links !== currentInputs.links) return false;
2383+
if (cached.inputs.reviews !== currentInputs.reviews) return false;
2384+
if (cached.inputs.githubActivity !== currentInputs.githubActivity) return false;
2385+
return true;
2386+
}
2387+
2388+
function refreshAuditChain(ingest) {
2389+
// events.jsonl이 매 capture마다 갱신 — auditChain만 신선화하고 캐시는 유지.
2390+
return { ...ingest, auditChain: buildAuditChainView() };
2391+
}
2392+
2393+
function sameMtimeList(a, b) {
2394+
if (!Array.isArray(a) || !Array.isArray(b)) return false;
2395+
if (a.length !== b.length) return false;
2396+
const byPath = new Map(b.map((entry) => [entry.path, entry.mtime]));
2397+
for (const entry of a) {
2398+
if (byPath.get(entry.path) !== entry.mtime) return false;
2399+
}
2400+
return true;
2401+
}
2402+
2403+
function getCachedOrBuildIngest(limit = 30) {
2404+
if (existsSync(ingestPath)) {
2405+
try {
2406+
const cached = JSON.parse(readFileSync(ingestPath, "utf8"));
2407+
const currentInputs = currentIngestInputs(limit);
2408+
if (isIngestCacheValid(cached, currentInputs)) {
2409+
return refreshAuditChain(cached);
2410+
}
2411+
} catch {
2412+
// fall through to rebuild
2413+
}
2414+
}
2415+
return buildAndStoreIngest(limit);
2416+
}
2417+
22892418
function scheduleIngestRebuild(limit = 30) {
22902419
setTimeout(() => {
22912420
try {

0 commit comments

Comments
 (0)